{ "openapi": "3.0.3", "info": { "title": "ZeroSettle IAP API", "description": "The ZeroSettle IAP API powers the iOS, Android, and Flutter SDKs. It enables product catalog fetching, web checkout, entitlement management, subscription lifecycle operations, and StoreKit transaction syncing. All endpoints are authenticated via the `X-ZeroSettle-Key` header.", "version": "1.0.0", "contact": { "name": "ZeroSettle Support", "email": "support@zerosettle.io", "url": "https://zerosettle.io" } }, "servers": [ { "url": "https://api.zerosettle.io/v1", "description": "Production" } ], "security": [ { "ApiKeyAuth": [] } ], "tags": [ { "name": "Products", "description": "Fetch the product catalog for your app" }, { "name": "Checkout", "description": "Create checkout sessions and payment intents for web purchases" }, { "name": "Transactions", "description": "Query transaction status and history" }, { "name": "Entitlements", "description": "Query user entitlements (active purchases and subscriptions)" }, { "name": "StoreKit", "description": "Sync and query StoreKit transactions" }, { "name": "Subscriptions", "description": "Manage subscription lifecycle (cancel, pause, resume)" }, { "name": "Cancel Flow", "description": "Configurable cancel flow with retention offers" }, { "name": "Upgrade Offers", "description": "Subscription upgrade/downgrade offers" }, { "name": "User Offer", "description": "Unified offer + subscription-state resolver (SDK 1.2+)" }, { "name": "Migration", "description": "Switch & Save migration tracking" }, { "name": "Events", "description": "Track SDK analytics events" }, { "name": "Restore", "description": "Restore purchases for a user" }, { "name": "Identity", "description": "Move a guest's purchases to their account (reconcile two ids)" } ], "paths": { "/iap/products/": { "get": { "operationId": "getProducts", "summary": "Get Products", "description": "Fetches the product catalog for your app, including web, StoreKit and Google Play prices, trial details, checkout configuration and any active Switch & Save or upgrade offer for the user. Pass `user_id` to get per-user trial eligibility and offers.", "tags": [ "Products" ], "parameters": [ { "name": "user_id", "in": "query", "required": false, "description": "The user's external ID. When provided, the response includes per-user `is_trial_eligible` on each product.", "schema": { "type": "string" } }, { "name": "storefront", "in": "query", "required": false, "description": "Two-letter storefront country code (for example `US`). Send it so the displayed price, routing and trial match what checkout will charge for region-specific pricing and experiments.", "schema": { "type": "string" } }, { "name": "demo", "in": "query", "required": false, "description": "**Debug-only.** Surfaces a dashboard-configured campaign as if the requesting user were eligible for it, regardless of their real subscription state. Lets developers preview their tip UI without crafting a specific test account.\n\n- `migration` returns the configured `MigrationCampaign` (Switch & Save) on `config.migration` (and `config.offer` mirror with `flow_type=migration`).\n- `upgrade` returns the configured `UpgradeOfferConfig` (Upgrade & Save) on `config.offer` with `flow_type=upgrade`. Uses the first `upgrade_paths` entry as the preview source/target. `savings_percent` is `0` for demo previews — accurate proration requires a real billing cycle.\n- Honored **only** on test-mode publishable keys (`zs_pk_test_*`). Live keys silently ignore the flag. Impressions are NOT counted on demo requests. Any other value (including the legacy `true`) drops the bypass and the normal user-state resolver runs.", "schema": { "type": "string", "enum": [ "migration", "upgrade" ] } } ], "responses": { "200": { "description": "Product catalog with checkout configuration", "content": { "application/json": { "schema": { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } }, "config": { "type": "object", "properties": { "checkout": { "$ref": "#/components/schemas/CheckoutConfig" }, "migration": { "description": "Active Switch & Save campaign for this user, or null when the user is anonymous or not eligible.", "allOf": [ { "$ref": "#/components/schemas/MigrationCampaign" } ], "nullable": true }, "offer": { "description": "Unified offer for this user (Switch & Save or upgrade), or null when the user is anonymous or not eligible. New SDKs prefer this over `migration`.", "allOf": [ { "$ref": "#/components/schemas/Offer" } ], "nullable": true } }, "required": [ "checkout" ], "description": "Remote configuration for the SDK." } }, "required": [ "products", "config" ] } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/payment-intents/": { "post": { "operationId": "createPaymentIntent", "summary": "Create Payment Intent", "description": "Creates a Stripe PaymentIntent (or SetupIntent for free trials) for web checkout. Returns the `client_secret` and a `checkout_url` for the hosted checkout page. SDK 1.3.0 and later send `X-ZS-SDK-Version` and get `410 use_checkout_configs` here: the SDK uses a different endpoint for those versions and handles it itself, so you only call this endpoint directly from a custom integration.", "tags": [ "Checkout" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "product_id" ], "properties": { "product_id": { "type": "string", "description": "The product's `reference_id` (e.g., `com.app.premium`)." }, "external_user_id": { "type": "string", "description": "The user's external ID. Either this or `user_id` is required." }, "user_id": { "type": "string", "description": "Alias for `external_user_id`. Either this or `external_user_id` is required." }, "platform": { "type": "string", "enum": [ "ios", "android", "web" ], "description": "The platform the purchase is being made from." }, "stripe_customer_id": { "type": "string", "description": "Existing Stripe customer ID to attach the payment to." }, "customer_name": { "type": "string", "description": "Optional customer display name to attach to the Stripe customer for this checkout. Also accepts the camelCase alias `customerName`." }, "customer_email": { "type": "string", "description": "Optional customer email to attach to the Stripe customer for this checkout. Also accepts the camelCase alias `customerEmail`." }, "checkout_mode": { "type": "string", "enum": [ "native", "browser" ], "description": "`native` (default) returns a `checkout_url` for the embedded checkout page shown in the SDK web view. `browser` returns a URL for the same checkout opened in a browser." }, "storekit_subscription_end": { "type": "string", "format": "date-time", "description": "ISO 8601 datetime of the current StoreKit subscription's expiry. Used for Switch & Save migrations to align the trial period." }, "storekit_original_transaction_id": { "type": "string", "description": "The StoreKit original transaction ID for the subscription being migrated." }, "external_purchase_token": { "type": "string", "description": "Apple externalPurchaseToken received from StoreKit at the disclosure-sheet confirmation step. When provided, ZeroSettle uses this token for automatic external-purchase reporting in eligible jurisdictions (EU/EEA under the DMA alternative terms, EEA music streaming, and Japan MSCA on iOS 26.4+). Optional — omit for non-Apple platforms or regions where reporting is not required." }, "ios_version": { "type": "string", "description": "iOS version of the checkout client (e.g., '26.4.1'). Used for JP MSCA regime gating, which requires iOS 26+. Optional." }, "storefront": { "type": "string", "description": "Two-letter storefront country code. Used for region-specific pricing and experiments." }, "rc_app_user_id": { "type": "string", "description": "RevenueCat app user ID, if using the RevenueCat integration." }, "play_purchase_token": { "type": "string", "description": "Google Play purchase token for the subscription being migrated (Android Switch & Save)." } } } } } }, "responses": { "200": { "description": "Payment intent created successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaymentIntentResponse" } } } }, "400": { "description": "Invalid request. Includes `code` values `customer_conflict` and `invalid_customer` (bad `stripe_customer_id`) and `stripe_account_restricted` (connected Stripe account setup is incomplete).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "description": "`code` is `tenant_disabled` (merchant account disabled) or `not_whitelisted` (live key on an account not yet approved for live transactions). A routing experiment can also return 403 `Web checkout is not available for this user`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Product not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`already_entitled`: the user already has an active entitlement for this one-time product. Or `in_flight`: a checkout for this user and product is being prepared; retry after `retry_after` seconds.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "410": { "description": "`use_checkout_configs`: the request carried `X-ZS-SDK-Version` 1.3.0 or later. SDKs of that version use a different endpoint and handle this themselves.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "`checkout_domain_not_configured`: the server has no checkout domain configured.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe error. `code` is `stripe_error`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "`migration_unavailable` (Switch & Save is unavailable right now) or server busy with `retry_after`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/payment-intents/batch/": { "post": { "operationId": "createPaymentIntentBatch", "summary": "Create Payment Intent Batch", "description": "Creates Stripe PaymentIntents for multiple products in a single request. Reduces N sequential requests to 1, with shared Stripe customer/account resolution. Products that fail validation return an error entry instead of failing the entire batch. Maximum 25 products per request.", "tags": [ "Checkout" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "products" ], "properties": { "products": { "type": "array", "description": "Array of products to create payment intents for. Maximum 25 items.", "maxItems": 25, "items": { "type": "object", "required": [ "product_id" ], "properties": { "product_id": { "type": "string", "description": "The product's `reference_id` (e.g., `com.app.premium`)." }, "storekit_subscription_end": { "type": "string", "format": "date-time", "description": "ISO 8601 datetime of the current StoreKit subscription's expiry. Used for Switch & Save migrations." }, "storekit_original_transaction_id": { "type": "string", "description": "The StoreKit original transaction ID for the subscription being migrated." } } } }, "user_id": { "type": "string", "description": "The user's external ID." }, "external_user_id": { "type": "string", "description": "Alias for `user_id`." }, "platform": { "type": "string", "enum": [ "ios", "android", "web" ], "description": "The platform the purchase is being made from." }, "checkout_mode": { "type": "string", "enum": [ "native", "browser" ], "description": "`native` (default) or `browser`. See Create Payment Intent." }, "stripe_customer_id": { "type": "string", "description": "Existing Stripe customer ID to attach payments to." }, "customer_name": { "type": "string", "description": "Optional customer display name to attach to the Stripe customer for this batch. Also accepts the camelCase alias `customerName`." }, "customer_email": { "type": "string", "description": "Optional customer email to attach to the Stripe customer for this batch. Also accepts the camelCase alias `customerEmail`." }, "storefront": { "type": "string", "description": "Two-letter storefront country code." }, "rc_app_user_id": { "type": "string", "description": "RevenueCat app user ID, if using the RevenueCat integration." } } } } } }, "responses": { "200": { "description": "Batch results with per-product success or error", "content": { "application/json": { "schema": { "type": "object", "required": [ "results" ], "properties": { "results": { "type": "array", "description": "Array of results, one per requested product. Each entry is either a successful PaymentIntent response or an error object.", "items": { "type": "object", "properties": { "product_id": { "type": "string", "description": "The product's `reference_id`." }, "client_secret": { "type": "string", "description": "Stripe PaymentIntent client secret. Present on success." }, "transaction_id": { "type": "string", "description": "ZeroSettle transaction ID. Present on success." }, "amount": { "type": "integer", "description": "Charge amount in cents. Present on success." }, "currency": { "type": "string", "description": "ISO 4217 currency code. Present on success." }, "product_name": { "type": "string", "description": "Display name of the product. Present on success." }, "original_amount": { "type": "integer", "description": "Full price before discounts/trials. Present on success." }, "checkout_url": { "type": "string", "description": "Fallback URL for browser-based checkout. Present on success." }, "publishable_key": { "type": "string", "description": "Stripe publishable key for confirming the payment. Present on success." }, "stripe_account": { "type": "string", "nullable": true, "description": "Connected Stripe account ID for BYOS. Null for managed mode." }, "merchant_country": { "type": "string", "nullable": true, "description": "ISO 3166-1 alpha-2 country code for Apple Pay." }, "is_subscription": { "type": "boolean", "description": "Whether this product is a subscription. Present on success." }, "subscription_interval": { "type": "string", "description": "Subscription billing interval (e.g., `month`, `week`). Present for subscriptions." }, "trial_type": { "type": "string", "enum": [ "free_trial", "migration" ], "nullable": true, "description": "Type of trial applied. Omitted when no trial." }, "trial_end": { "type": "integer", "nullable": true, "description": "Unix timestamp when trial ends. Only present with active trial." }, "pending_amount": { "type": "integer", "nullable": true, "description": "Amount billed when trial ends. Only present with active trial." }, "error": { "type": "string", "description": "Error code or message. Present when this product failed, for example `in_flight` or `already_entitled`." }, "callback_url": { "type": "string", "description": "URL the checkout page returns to after payment. Present on success." }, "charge_amount": { "type": "integer", "description": "Cents the intent charges or holds now. Present for trial checkouts." }, "intent_type": { "type": "string", "enum": [ "setup", "payment" ], "description": "Which Stripe confirm method to call. Present on success." }, "message": { "type": "string", "description": "Detail for an error entry." }, "retry_after": { "type": "integer", "description": "Seconds to wait before retrying. Sent with an `in_flight` error entry." } } } } } } } } }, "400": { "description": "Invalid request, or Stripe account setup incomplete (`code` `stripe_account_restricted`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "description": "`tenant_disabled` or `not_whitelisted`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "`checkout_domain_not_configured`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe error. `code` is `stripe_error`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Server busy, with `retry_after`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/checkout-sessions/": { "post": { "operationId": "createCheckoutSession", "summary": "Create Checkout Session", "description": "Creates a Stripe Checkout Session for browser-based checkout. Returns a URL to redirect the user to Stripe's hosted checkout page.", "tags": [ "Checkout" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "product_id" ], "properties": { "product_id": { "type": "string", "description": "The product's `reference_id` (e.g., `com.app.premium`)." }, "external_user_id": { "type": "string", "description": "The user's external ID. Either this or `user_id` is required." }, "user_id": { "type": "string", "description": "Alias for `external_user_id`. Either this or `external_user_id` is required." }, "platform": { "type": "string", "enum": [ "ios", "android", "web" ], "description": "The platform the purchase is being made from." }, "stripe_customer_id": { "type": "string", "description": "Existing Stripe customer ID to attach the payment to." }, "rc_app_user_id": { "type": "string", "description": "RevenueCat app user ID, if using RevenueCat integration." }, "storefront": { "type": "string", "description": "Two-letter storefront country code. Used for region-specific pricing and experiments." }, "play_purchase_token": { "type": "string", "description": "Google Play purchase token for the subscription being migrated (Android Switch & Save)." }, "storekit_subscription_end": { "type": "string", "description": "ISO 8601 expiry of the StoreKit subscription being migrated.", "format": "date-time" }, "storekit_original_transaction_id": { "type": "string", "description": "Original transaction ID of the StoreKit subscription being migrated." } } } } } }, "responses": { "200": { "description": "Checkout session created successfully", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CheckoutSessionResponse" } } } }, "400": { "description": "Invalid request (missing `product_id` or user id, or `stripe_customer_id` not starting with `cus_`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "description": "`code` is `tenant_disabled` (merchant account disabled) or `not_whitelisted` (live key on an account not yet approved for live transactions). A routing experiment can also return 403 `Web checkout is not available for this user`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Product not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`already_entitled`: the user already has an active entitlement for this one-time product. Or `in_flight`: a checkout for this user and product is being prepared; retry after `retry_after` seconds.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "`checkout_domain_not_configured`: the server has no checkout domain configured.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Server busy, with `retry_after`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/transactions/{transaction_id}/": { "get": { "operationId": "getTransaction", "summary": "Get Transaction", "description": "Retrieves the status of a transaction by the `transaction_id` returned from checkout. Poll this after a checkout to confirm the payment settled.", "tags": [ "Transactions" ], "parameters": [ { "name": "transaction_id", "in": "path", "required": true, "description": "The transaction ID (UUID returned from checkout).", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Transaction details", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Transaction" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/iap/transactions/{transaction_id}/storekit-status/": { "patch": { "operationId": "updateStorekitStatus", "summary": "Update StoreKit Status", "description": "Reports a StoreKit subscription state change for a migration transaction. As of 2026-04-27, this is a *trigger to verify* endpoint: when the SDK reports `storekit_status=2` (anything other than active), the backend queries Apple's App Store Server API itself and persists what Apple reports onto the transaction's `storekit_apple_status` and `storekit_subscription_end` fields. Billing-relevant data is never sourced from the client.", "tags": [ "StoreKit" ], "parameters": [ { "name": "transaction_id", "in": "path", "required": true, "description": "The transaction ID.", "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "storekit_status" ], "properties": { "storekit_status": { "type": "integer", "enum": [ 1, 2 ], "description": "`1` = active (finished successfully, no further action). `2` = anything-not-active (cancelled, expired, billing retry, grace period, revoked). When `2` is reported, the backend triggers a server-side Apple App Store Server API query to resolve the canonical state." }, "storekit_original_transaction_id": { "type": "string", "description": "Apple `originalTransactionId` for the subscription. Used by the backend to query Apple's API for the canonical subscription state when `storekit_status=2`. Optional — the backend will fall back to the value stored at transaction creation time when omitted." }, "storekit_subscription_end": { "type": "string", "format": "date-time", "deprecated": true, "description": "Deprecated as of 2026-04-27. Accepted for backwards compatibility but **ignored** — the backend now resolves the actual subscription expiry from Apple's App Store Server API. Existing SDKs may continue sending this field; new SDKs should omit it." } } } } } }, "responses": { "200": { "description": "Status updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean", "description": "Always `true`." } }, "required": [ "ok" ] } } } }, "400": { "description": "`storekit_status` is not 1 or 2, or the body is not valid JSON.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/iap/transaction-history/": { "get": { "operationId": "getTransactionHistory", "summary": "Get Transaction History", "description": "Returns up to `limit` transactions for a user, most recent first. There is no cursor. Abandoned checkout sessions (a checkout replaced by a newer one for the same product) are not returned, and a disputed transaction is reported as `refunded`. Look a user up by `user_id`, or by `email` for legacy email-only checkouts.", "tags": [ "Transactions" ], "parameters": [ { "name": "user_id", "in": "query", "required": false, "description": "The user's external ID. Send either `user_id` or `email`.", "schema": { "type": "string" } }, { "name": "email", "in": "query", "required": false, "description": "Customer email. Matches only transactions recorded with that email (legacy email-only checkouts). Prefer `user_id`; email-only lookups are deprecated.", "schema": { "type": "string" } }, { "name": "limit", "in": "query", "required": false, "description": "Maximum number of transactions to return. Default: 50, Max: 100.", "schema": { "type": "integer", "default": 50, "maximum": 100 } } ], "responses": { "200": { "description": "Transaction history", "content": { "application/json": { "schema": { "type": "object", "required": [ "transactions" ], "properties": { "transactions": { "type": "array", "items": { "$ref": "#/components/schemas/TransactionHistoryItem" } } } } } } }, "400": { "description": "Neither `user_id` nor `email` was sent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/entitlements/": { "get": { "operationId": "getEntitlements", "summary": "Get Entitlements", "description": "Returns the entitlements a user currently has access to: active subscriptions and one-time purchases. Entitlements that have expired or are paused are not returned. Use this to decide what the user can access.", "tags": [ "Entitlements" ], "parameters": [ { "name": "user_id", "in": "query", "required": false, "description": "The user's external ID. Required unless `email` is provided.", "schema": { "type": "string" } }, { "name": "email", "in": "query", "required": false, "deprecated": true, "description": "The user's email address. Deprecated: use `user_id`. An email lookup matches only legacy email-only checkouts (identities with no external user id), so a user your app identified with a user id is never found by email.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "User entitlements", "content": { "application/json": { "schema": { "type": "object", "properties": { "entitlements": { "type": "array", "items": { "$ref": "#/components/schemas/Entitlement" } }, "pending_actions": { "type": "array", "description": "Follow-up actions for the app to show, for example cancelling an old store subscription after Switch & Save. Empty when there are none.", "items": { "type": "object", "additionalProperties": true } } }, "required": [ "entitlements" ] } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/claim-entitlement/": { "post": { "operationId": "claimEntitlement", "summary": "Claim Entitlement", "description": "Transfers an existing StoreKit entitlement to the calling user. Used when an Apple ID's StoreKit subscription is owned by a different ZeroSettle account and needs to be moved to the current account (account migration, support flows, sandbox testing). The backend verifies the supplied JWS, revokes the entitlement from the previous owner, and grants it to `user_id` in a single atomic transaction. The bundle id in the JWS is checked and logged on claim but does not refuse the request, unlike Sync StoreKit Transaction.", "tags": [ "Entitlements" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "jws_representation", "user_id" ], "properties": { "jws_representation": { "type": "string", "description": "The StoreKit 2 transaction's JWS representation string for the entitlement being claimed." }, "user_id": { "type": "string", "description": "The external user ID that should receive the entitlement." } } }, "example": { "jws_representation": "", "user_id": "user_123" } } } }, "responses": { "200": { "description": "Claim processed", "content": { "application/json": { "schema": { "type": "object", "required": [ "status", "claimed" ], "properties": { "status": { "type": "string", "example": "ok" }, "claimed": { "type": "boolean", "description": "True if the entitlement was transferred to the current user. False when the entitlement already belonged to the current user (no-op)." }, "product_id": { "type": "string", "description": "Reference ID of the claimed product. Present when `claimed` is true." }, "original_transaction_id": { "type": "string", "description": "StoreKit `originalTransactionId` of the claimed entitlement. Present when `claimed` is true." }, "message": { "type": "string", "description": "`Already owned by this account` when nothing moved. Present when `claimed` is false." }, "entitlement_states_transferred": { "type": "integer", "description": "Number of entitlement rows moved to the user. Present when `claimed` is true." }, "transactions_reassigned": { "type": "integer", "description": "Number of transactions reassigned to the user. Present when `claimed` is true." } } } } } }, "400": { "description": "Bad request. The error is one of: `Invalid JSON body`; `jws_representation is required`; `user_id is required`; `Cannot claim entitlement to a synthetic identity` (a `user_id` starting with `apple_otid:`); `Consumable products cannot be claimed`; `Missing productId in JWS payload`; `No originalTransactionId in JWS`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Invalid API key, or JWS verification failed: `JWS signature verification failed`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "`No entitlement found for this transaction`: nothing is recorded for the JWS `originalTransactionId`, so there is nothing to claim.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/storekit-transactions/": { "post": { "operationId": "syncStorekitTransaction", "summary": "Sync StoreKit Transaction", "description": "Forwards a StoreKit 2 JWS (JSON Web Signature) transaction to ZeroSettle for verification and recording. The backend verifies the JWS against Apple's root certificate chain, creates a `Transaction` and `Entitlement`, and returns success. The SDK should call `transaction.finish()` only after receiving a success response.\n\nSDK 1.3+ additionally forwards `will_auto_renew` (pulled from `Product.SubscriptionInfo.RenewalInfo.willAutoRenew`) on every sync. The backend diffs this against the stored entitlement state and emits `CANCELLED` / `REACTIVATED` events when the flag flips, so auto-renew state stays accurate even in deployments without App Store Server Notifications (ASSN) v2 configured.", "tags": [ "StoreKit" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "jws_representation": { "type": "string", "description": "The StoreKit 2 transaction's JWS representation string." }, "user_id": { "type": "string", "description": "The user's external ID." }, "will_auto_renew": { "type": "boolean", "description": "Optional (SDK 1.3+). The current `willAutoRenew` value from StoreKit's `Product.SubscriptionInfo.RenewalInfo` for the subscription this JWS belongs to. When provided, the backend compares it against the stored entitlement and emits `CANCELLED` (true→false) or `REACTIVATED` (false→true) events. Omit the field for non-subscription syncs or older SDKs; behaviour is unchanged when missing." }, "renewal_state": { "type": "string", "description": "Optional (SDK 1.3+, reserved). Mirror of StoreKit's `Product.SubscriptionInfo.RenewalState` for future use. Accepted but not currently acted on. Typical values: `subscribed`, `expired`, `in_billing_retry_period`, `in_grace_period`, `revoked`.", "enum": [ "subscribed", "expired", "in_billing_retry_period", "in_grace_period", "revoked" ] }, "transactions": { "type": "array", "description": "Bulk reconcile mode: send this instead of `jws_representation`. Each entry has `signed_transaction` and optionally `signed_renewal_info`. See the notes on this page.", "items": { "type": "object", "properties": { "signed_transaction": { "type": "string" }, "signed_renewal_info": { "type": "string" } } } }, "customer_name": { "type": "string", "description": "Optional. Used to fill the customer name when none is recorded. Also accepts `customerName`." }, "customer_email": { "type": "string", "description": "Optional. Used to fill the customer email when none is recorded. Also accepts `customerEmail`." } }, "description": "Send either `jws_representation` (a single transaction) or `transactions` (bulk reconcile). `user_id` is always required." } } } }, "responses": { "200": { "description": "Transaction synced successfully, or a conflict was reported. If `owned` is `false` with `conflict: true`, another user already owns this Apple subscription: nothing was granted, and `claim_available` tells the app it can offer Claim Entitlement.", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "description": "Always `ok`.", "example": "ok" }, "owned": { "type": "boolean", "description": "`true` when the entitlement now belongs to `user_id`. `false` when another user owns it (see `conflict`)." }, "original_transaction_id": { "type": "string", "description": "Apple `originalTransactionId` of the subscription or purchase." }, "resolved_via": { "type": "string", "description": "How an ownership change was resolved on the server: `none` for an ordinary sync, or a value such as `apple_verified_ownership_change` or `orphan_auto_resolve`. Informational." }, "is_test_purchase": { "type": "boolean", "description": "`true` when Apple processed the transaction as a sandbox purchase (TestFlight, App Review or Xcode) but the request used a live key. The purchase is saved in Live as a test purchase and is left out of revenue." }, "conflict": { "type": "boolean", "description": "`true` when another user owns this Apple subscription. Present only on a conflict." }, "claim_available": { "type": "boolean", "description": "Present on a conflict: the app can offer Claim Entitlement." }, "existing_owner_hint": { "type": "string", "description": "Present on a conflict: a short non-reversible hash that identifies the other owner without revealing it." }, "warning": { "type": "string", "enum": [ "environment_mismatch" ], "description": "Present when the Apple environment does not match the key." }, "warning_message": { "type": "string", "description": "Human-readable detail for `warning`." } }, "required": [ "status" ] }, "example": { "status": "ok", "owned": true, "original_transaction_id": "2000000123456789", "resolved_via": "none", "is_test_purchase": false } } } }, "400": { "description": "Bad request. `error` is `Invalid JSON body`, `jws_representation is required`, `user_id is required` or `Missing productId in JWS payload`. With `code` `bundle_id_mismatch`: the JWS `bundleId` does not belong to this app (see below).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Invalid API key, or JWS verification failed: `JWS signature verification failed`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The StoreKit transaction id already belongs to a different project, so it cannot be recorded here. Nothing is saved.", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string", "example": "storekit_transaction_id_conflict" }, "message": { "type": "string", "example": "This StoreKit transaction id already belongs to a different project." } } } } } } } } }, "/iap/storekit-subscription-status/": { "get": { "operationId": "getStorekitSubscriptionStatus", "summary": "Get StoreKit Subscription Status", "description": "Returns the current status of a StoreKit subscription by its original transaction ID. Useful for checking renewal state and expiry. The response includes normalized `is_active` and `auto_renew_enabled` booleans (SDK 1.2+); prefer these over the raw `status`/`auto_renew_status` fields for canonical access decisions.", "tags": [ "StoreKit" ], "parameters": [ { "name": "original_transaction_id", "in": "query", "required": true, "description": "The StoreKit original transaction ID for the subscription.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Subscription status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StorekitSubscriptionStatus" } } } }, "400": { "description": "`original_transaction_id` query parameter is missing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "502": { "description": "The lookup against Apple failed or returned nothing (`Failed to query Apple subscription status`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/user-offer/": { "get": { "operationId": "getUserOffer", "summary": "Get User Offer", "description": "Canonical offer + subscription-state resolver (SDK 1.2+). Returns the user's current subscription variant plus exactly one offer decision (`no_action`, `migrate_storekit_to_web`, `migrate_play_to_web`, `upgrade_storekit_to_web`, or `upgrade_web_to_web`). Replaces the overlapping `/iap/products/` offer config, `/iap/upgrade-offer/`, and ad-hoc status parsing with a single typed contract. Legacy endpoints remain supported for older SDKs.", "tags": [ "User Offer" ], "parameters": [ { "name": "user_id", "in": "query", "required": true, "description": "The user's external ID.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Canonical user offer + subscription state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserOfferResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, "/iap/migration-converted/": { "post": { "operationId": "migrationConverted", "summary": "Report Migration Conversion", "description": "Records that a Switch & Save migration was completed. It counts a conversion on the campaign. It does not record impressions.", "tags": [ "Migration" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "variant_id": { "type": "integer", "description": "The experiment variant ID, if the migration campaign is part of an A/B test." }, "source": { "type": "string", "enum": [ "store_kit", "play_store" ], "default": "store_kit", "description": "The storefront the user migrated away from. Defaults to `store_kit`." } } } } } }, "responses": { "200": { "description": "Conversion recorded", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" }, "conversions": { "type": "integer", "description": "Total number of conversions for this campaign." } } } } } }, "400": { "description": "`source` is not `store_kit` or `play_store`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No migration campaign found` for this environment and variant.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/subscriptions/cancel/": { "post": { "operationId": "cancelSubscription", "summary": "Cancel Subscription", "description": "Cancels a user's web checkout (Stripe) subscription. By default access continues until the end of the current billing period. Set `immediate: true` to cancel now. A Switch & Save migration trial is always cancelled immediately. App Store and Google Play subscriptions cannot be cancelled here: they return 400 `No Stripe subscription linked`.", "tags": [ "Subscriptions" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "product_id": { "type": "string", "description": "Specific product to cancel. If omitted, cancels the user's active subscription." }, "immediate": { "type": "boolean", "default": false, "description": "If `true`, cancels immediately and revokes access. If `false`, cancels at end of billing period." } } }, "example": { "user_id": "user_123", "immediate": false } } } }, "responses": { "200": { "description": "Subscription cancelled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CancelResponse" }, "example": { "success": true, "status": "cancelled", "cancelled_at": "2026-10-26T12:00:00+00:00" } } } }, "400": { "description": "`user_id is required`, `Invalid JSON body`, or `No Stripe subscription linked` (the subscription was not bought through web checkout).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No active subscription found`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe returned an error (`Stripe error: ...`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/subscriptions/pause/": { "post": { "operationId": "pauseSubscription", "summary": "Pause Subscription", "description": "Pauses a user's web checkout (Stripe) subscription. With `pause_duration_days` it resumes automatically after that many days; without it the pause is indefinite until Resume Subscription is called.", "tags": [ "Subscriptions" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "product_id": { "type": "string", "description": "Specific product to pause. If omitted, pauses the user's active subscription." }, "pause_duration_days": { "type": "integer", "description": "Number of days to pause. If omitted or 0, the subscription is paused until you resume it." } } } } } }, "responses": { "200": { "description": "Subscription paused", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PauseResponse" } } } }, "400": { "description": "`user_id is required`, `Invalid JSON body`, or `No Stripe subscription linked`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No active subscription found`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe returned an error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/subscriptions/resume/": { "post": { "operationId": "resumeSubscription", "summary": "Resume Subscription", "description": "Resumes a previously paused web checkout subscription.", "tags": [ "Subscriptions" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "product_id": { "type": "string", "description": "Specific product to resume. If omitted, resumes the user's paused subscription." } } } } } }, "responses": { "200": { "description": "Subscription resumed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResumeResponse" } } } }, "400": { "description": "`user_id is required`, `Invalid JSON body`, or `No Stripe subscription linked`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No paused subscription found`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe returned an error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/customer-portal-sessions/": { "post": { "operationId": "createCustomerPortalSession", "summary": "Create Customer Portal Session", "description": "Creates a Stripe Customer Portal session URL. The portal lets users manage their subscriptions, update payment methods, and view billing history.", "tags": [ "Customer Portal" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "return_url": { "type": "string", "description": "URL to redirect the user to after they leave the portal." } } } } } }, "responses": { "200": { "description": "Portal session created", "content": { "application/json": { "schema": { "type": "object", "properties": { "portal_url": { "type": "string", "description": "URL to redirect the user to the Stripe Customer Portal." } } } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No billing record found for this user`. Only web checkout subscribers have a billing portal.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe returned an error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/cancel-flow/": { "get": { "operationId": "getCancelFlow", "summary": "Get Cancel Flow", "description": "Returns the cancel flow configured in your dashboard: survey questions, a retention offer, pause options and, on current SDKs, deflection and downgrade blocks. When the flow is disabled or not configured the response is `{\"enabled\": false}`. The shape depends on the SDK: see the notes on this page.", "tags": [ "Cancel Flow" ], "parameters": [ { "name": "user_id", "in": "query", "required": false, "description": "The user's external ID. Used to personalize offers.", "schema": { "type": "string" } }, { "name": "storefront", "in": "query", "required": false, "description": "Two-letter storefront country code, used for geo-targeted experiments.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Cancel flow configuration", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CancelFlow" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/cancel-flow/respond/": { "post": { "operationId": "submitCancelFlowResponse", "summary": "Submit Cancel Flow Response", "description": "Submits the user's responses from the cancel flow, including survey answers and the final outcome (cancelled, retained, dismissed, or paused). Used for cancel flow analytics.", "tags": [ "Cancel Flow" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id", "outcome" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "product_id": { "type": "string", "description": "The product being cancelled." }, "outcome": { "type": "string", "enum": [ "cancelled", "retained", "dismissed", "paused" ], "description": "The final outcome of the cancel flow." }, "offer_shown": { "type": "boolean", "description": "Whether a retention offer was shown to the user." }, "offer_accepted": { "type": "boolean", "description": "Whether the user accepted the retention offer." }, "pause_shown": { "type": "boolean", "description": "Whether a pause option was shown." }, "pause_accepted": { "type": "boolean", "description": "Whether the user chose to pause." }, "pause_duration_days": { "type": "integer", "description": "Number of days the user chose to pause, if applicable." }, "last_step_seen": { "type": "integer", "description": "Index of the last cancel flow step the user saw. Defaults to 0." }, "variant_id": { "type": "integer", "description": "Experiment variant ID, if the cancel flow is part of an A/B test." }, "answers": { "type": "array", "description": "Survey answers.", "items": { "type": "object", "properties": { "question_id": { "type": "integer", "description": "ID of the question, from Get Cancel Flow." }, "selected_option_id": { "type": "integer", "description": "ID of the chosen option, from Get Cancel Flow." }, "free_text": { "type": "string", "description": "Free-text answer, if the question allows one." } } } } } } } } }, "responses": { "200": { "description": "Response recorded", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean", "description": "Always `true`." } }, "required": [ "success" ] } } } }, "400": { "description": "`Invalid JSON body`, `user_id is required`, or `outcome must be cancelled, retained, dismissed, or paused`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/cancel-flow/accept-offer/": { "post": { "operationId": "acceptCancelFlowOffer", "summary": "Accept Cancel Flow Offer", "description": "Accepts the retention offer from the cancel flow and applies it to the user's Stripe subscription: a percentage discount for a number of months, or a free period. Calling it again for a subscription that already carries the offer returns the offer that is applied.", "tags": [ "Cancel Flow" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id", "product_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "product_id": { "type": "string", "description": "The product the offer applies to." } } }, "example": { "user_id": "user_123", "product_id": "com.example.pro.monthly" } } } }, "responses": { "200": { "description": "Offer accepted", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string", "description": "Human-readable summary, for example `20% off for 3 months`." }, "discount_percent": { "type": "integer", "description": "Percent discount applied. Null for a free-period offer.", "nullable": true }, "duration_months": { "type": "integer", "description": "Months the discount lasts. Null for a free-period offer or a forever discount.", "nullable": true } }, "required": [ "message" ] }, "example": { "message": "20% off for 3 months", "discount_percent": 20, "duration_months": 3 } } } }, "400": { "description": "`user_id is required`, `product_id is required`, `Save offer is not enabled`, `No Stripe subscription linked`, or `Invalid offer configuration`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`Cancel flow not configured`, or `No active subscription found`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Stripe returned an error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/upgrade-offer/": { "get": { "operationId": "getUpgradeOffer", "summary": "Get Upgrade Offer", "description": "Checks whether a user can be offered a subscription upgrade and returns the offer. `available` is `false` with a `reason` when there is no offer.", "tags": [ "Upgrade Offers" ], "parameters": [ { "name": "user_id", "in": "query", "required": true, "description": "The user's external ID.", "schema": { "type": "string" } }, { "name": "product_id", "in": "query", "required": false, "description": "Specific product to check upgrade eligibility for.", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Upgrade offer details", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpgradeOffer" } } } }, "400": { "description": "`user_id query parameter is required`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/upgrade-offer/execute/": { "post": { "operationId": "executeUpgradeOffer", "summary": "Execute Upgrade Offer", "description": "Starts the upgrade. For a web subscription (`web_to_web`) the existing Stripe subscription is changed to the target plan in place, with proration, and the response carries the new entitlement. There is no client secret. For an App Store subscriber (`storekit_to_web`) the response tells the app which product to check out: call Create Checkout Session or Create Payment Intent for `target_product_id`, then show the cancel instructions.", "tags": [ "Upgrade Offers" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id", "current_product_id", "target_product_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "current_product_id": { "type": "string", "description": "The reference ID of the user's current subscription product." }, "target_product_id": { "type": "string", "description": "The reference ID of the product to upgrade to." } } }, "example": { "user_id": "user_123", "current_product_id": "com.example.pro.monthly", "target_product_id": "com.example.pro.annual" } } } }, "responses": { "200": { "description": "Upgrade processed", "content": { "application/json": { "schema": { "oneOf": [ { "$ref": "#/components/schemas/UpgradeExecuteResponse" }, { "$ref": "#/components/schemas/UpgradeExecuteStorekitResponse" } ] } } } }, "400": { "description": "`user_id`, `current_product_id` or `target_product_id` is missing, or the upgrade could not be performed (the message is in `error`, for example `Target product not found`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "description": "`No active subscription found` for `current_product_id`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/iap/upgrade-offer/respond/": { "post": { "operationId": "respondUpgradeOffer", "summary": "Respond to Upgrade Offer", "description": "Records that the user declined or dismissed an upgrade offer, for analytics. Accepting an offer is recorded by Execute Upgrade Offer, not here.", "tags": [ "Upgrade Offers" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "user_id", "outcome", "current_product_id", "target_product_id" ], "properties": { "user_id": { "type": "string", "description": "The user's external ID." }, "variant_id": { "type": "integer", "description": "Experiment variant ID, if the upgrade offer is part of an A/B test." }, "outcome": { "type": "string", "enum": [ "declined", "dismissed" ], "description": "What the user did with the offer." }, "current_product_id": { "type": "string", "description": "Reference ID of the user's current product." }, "target_product_id": { "type": "string", "description": "Reference ID of the offered product." } } }, "example": { "user_id": "user_123", "outcome": "declined", "current_product_id": "com.example.pro.monthly", "target_product_id": "com.example.pro.annual" } } } }, "responses": { "200": { "description": "Response recorded", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean", "description": "Always `true`." } }, "required": [ "success" ] } } } }, "400": { "description": "`Invalid JSON body`, `user_id is required`, `outcome must be 'declined' or 'dismissed'`, or `current_product_id and target_product_id are required`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/events/": { "post": { "operationId": "trackEvent", "summary": "Track Event", "description": "Tracks a funnel event from the SDK. `event_type` must be one of `paywall_viewed`, `checkout_started` or `checkout_abandoned`. `event_type`, `user_id` and `product_id` are all required.", "tags": [ "Events" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "event_type", "user_id", "product_id" ], "properties": { "event_type": { "type": "string", "enum": [ "paywall_viewed", "checkout_started", "checkout_abandoned" ], "description": "The funnel event type. Any other value returns 400." }, "user_id": { "type": "string", "description": "The user's external ID. Before `identify`, SDKs send a per-device UUID (the SDK's `analyticsUserId`), the same one `identify(.anonymous)` adopts later, instead of the literal `anonymous`. A guest-to-account reconcile moves these events to the account." }, "product_id": { "type": "string", "description": "The product the event relates to. Required." }, "environment": { "type": "string", "description": "Optional. The StoreKit environment the device is running in (`Production`, `Sandbox` or `Xcode`), sent by the iOS SDK. A non-production environment sent with a live key marks the event as a test event. Omitted when unknown, and an omitted value is never treated as a test event." }, "screen_name": { "type": "string", "description": "The screen or view where the event occurred." }, "metadata": { "type": "object", "additionalProperties": true, "description": "Additional key-value metadata for the event." } } }, "example": { "event_type": "paywall_viewed", "user_id": "user_123", "product_id": "com.example.pro.monthly", "screen_name": "home_paywall" } } } }, "responses": { "201": { "description": "Event recorded", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } }, "example": { "status": "ok" } } } }, "400": { "description": "`Invalid JSON body`; `event_type, user_id, and product_id are required`; or `Invalid event_type. Must be one of: ...`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/iap/restore-purchases/": { "post": { "operationId": "restorePurchases", "summary": "Restore Purchases", "description": "Moves purchases made by an anonymous (email-only) customer to the current user ID, matched by email. Useful when a user reinstalls the app or signs in on a new device. The ZeroSettle SDKs do not call this endpoint: it is for your own restore flow.\n\nEach matching guest identity is moved through the same reconcile core as Reconcile Identities. If the guest and the target hold a live entitlement to the same non-consumable product (a subscription, including a trial or one cancelled but still running out its period, or a one-time unlock; consumables never conflict), that guest is refused and **nothing is restored from it**. The response is still `200`, with `restored` of `0` for it and a `message` telling the person to contact support.\n\nA move that happens sends an `identity.reconciled` webhook, not `subscription.purchased`. The guest's Stripe links and profile are not moved by this endpoint.\n\nRate limited per IP (10 an hour), per email and app (5 an hour) and per app (6,000 a minute). Over a limit the response is `429` with a `Retry-After` header. If the rate-limit store is unavailable, the per-IP and per-email limits refuse the call with `429` and `Retry-After: 5`. A restore button should treat a `429` as a failed restore and offer a retry after the delay.", "tags": [ "Restore" ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "email", "user_id" ], "properties": { "email": { "type": "string", "description": "The user's email address to match against previous purchases." }, "user_id": { "type": "string", "description": "The user's current external ID to restore purchases to." } } }, "example": { "email": "person@example.com", "user_id": "user_123" } } } }, "responses": { "200": { "description": "Processed. Check `restored` and `message`.", "content": { "application/json": { "schema": { "type": "object", "properties": { "restored": { "type": "integer", "description": "Number of entitlements restored. `0` when nothing matched or every match was refused." }, "target_identity_id": { "type": "integer", "description": "The ZeroSettle identity id of the user restored to. An integer, including when a conflict refused the restore. Absent only when no guest purchases matched the email." }, "message": { "type": "string", "description": "A short human-readable result. On a refused conflict: `Some purchases could not be restored automatically. Contact support.`" } }, "required": [ "restored" ] }, "examples": { "restored": { "value": { "restored": 1, "target_identity_id": 9124, "message": "Restored 1 entitlement(s)" } }, "conflict": { "value": { "restored": 0, "target_identity_id": 9124, "message": "Some purchases could not be restored automatically. Contact support." } }, "nothing_found": { "value": { "restored": 0, "message": "No anonymous purchases found for this email" } } } } } }, "400": { "description": "`Invalid JSON body`, or `email and user_id are required`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "429": { "description": "Too many restore attempts.", "headers": { "Retry-After": { "description": "Seconds to wait before retrying. `5` when the rate-limit store is unavailable.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string", "example": "Too many restore attempts. Try again later." } } } } } } } } }, "/iap/migration-cohort/": { "get": { "operationId": "getMigrationCohort", "summary": "Get Migration Cohort", "tags": [ "Migration" ], "description": "Server-to-server. Lists the users who are currently eligible for your Switch & Save offer, so your backend can target them (for example with email or push). Authenticate with your **secret** key, never the publishable key. The environment (sandbox or live) comes from the key. This is an enumeration: it does not count impressions. Eligibility follows the same rules as the offer the SDK shows. When no campaign is enabled the list is empty.", "security": [ { "SecretKeyAuth": [] } ], "parameters": [ { "name": "cursor", "in": "query", "required": false, "description": "Pagination cursor: the `next_cursor` from the previous page.", "schema": { "type": "string" } }, { "name": "limit", "in": "query", "required": false, "description": "Page size. Default 100, maximum 500. Values below 1 are raised to 1.", "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 500 } } ], "responses": { "200": { "description": "A page of eligible users", "content": { "application/json": { "schema": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "object", "properties": { "external_user_id": { "type": "string", "description": "The user ID your app passed to `identify`." }, "product_id": { "type": "string", "description": "The product the user subscribes to." }, "subscription_started_at": { "type": "string", "description": "When the subscription started.", "format": "date-time" }, "eligible_since": { "type": "string", "description": "When the user became eligible: the start plus the campaign minimum subscription days.", "format": "date-time" }, "is_currently_eligible": { "type": "boolean", "description": "Always `true`: only eligible users are listed." } } } }, "next_cursor": { "type": "string", "nullable": true, "description": "Pass as `cursor` to get the next page. Null on the last page. A page can hold fewer users than `limit` only on the last page." }, "campaign": { "type": "object", "properties": { "is_enabled": { "type": "boolean", "description": "Whether the Switch & Save campaign is enabled." }, "rollout_percent": { "type": "integer", "description": "Percentage of eligible users the campaign rolls out to." } } } }, "required": [ "users", "next_cursor", "campaign" ] } } } }, "400": { "description": "`Invalid cursor`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "The `X-ZeroSettle-Key` header is missing, or the key is not a valid secret key (`Invalid secret key`). A publishable key is rejected.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/identities/reconcile/": { "post": { "operationId": "reconcileIdentities", "summary": "Reconcile Identities", "tags": [ "Identity" ], "security": [ { "SecretKeyAuth": [] } ], "description": "Moves one id's purchases, credits, analytics and experiment rows onto another id, from your server. Call it when a guest signs up and you know the guest id and the account id are the same person.\n\nAuthenticated with your **secret key** (`zs_sk_*`), sent in `X-ZeroSettle-Key` or as `Authorization: Bearer`. Never call it from an app. A publishable key is refused with `401 secret_key_required`.\n\nThe merge is one way, and an id that has been merged away cannot be merged again. If both customers hold a live entitlement to the same non-consumable product (a subscription, including a trial or one cancelled but still running out its period, or a one-time unlock; consumables never conflict), the call is refused and nothing moves. Stripe and the stores are never changed. If `from` has no customer record at all (no purchase, checkout or Stripe customer), only its analytics and experiment rows move: the call succeeds with zero counts, sends no event and sets up no forwarding. A guest with a customer record but no purchases takes a normal merge, with zero counts, forwarding and an `identity.reconciled` event. Purchased credits move and add to the account's balance; plan allowances do not move.\n\n**Idempotency.** Send an `Idempotency-Key`. A replay with the same key and the same ids within 24 hours returns the stored result. After 24 hours the same ids still return the original success. A key reused with different ids is refused with `422 idempotency_mismatch`, permanently: a key stays bound to the ids it first succeeded with. Refusals are not stored, so a retry after a refusal runs again.\n\nA merge sends an `identity.reconciled` webhook, including a merge that moved nothing. An analytics-only move sends none.\n\n**Limits.** 300 calls a minute and 50,000 a day per project, counted separately from link-token presentations. Over a limit the response is `429` with `Retry-After`.", "parameters": [ { "name": "Idempotency-Key", "in": "header", "required": true, "description": "A unique key for this reconcile, 1 to 255 characters. A key stays bound to the ids it first succeeded with: reuse it only when retrying the same call.", "schema": { "type": "string", "minLength": 1, "maxLength": 255 } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "from", "into" ], "properties": { "from": { "type": "string", "maxLength": 255, "description": "The id to merge away, usually the guest id. Ids are matched without regard to letter case." }, "into": { "type": "string", "maxLength": 255, "description": "The id that keeps the purchases, usually the account id. If this id was merged away earlier, ZeroSettle follows it to the id that kept it." }, "reason": { "type": "string", "maxLength": 500, "description": "Optional note stored on the reconciliation record." } } }, "example": { "from": "3f1c9c52-8f5e-4a52-9d9c-0f0b6f1a7c11", "into": "user_8472", "reason": "signup" } } } }, "responses": { "200": { "description": "The ids were reconciled, or this is the replay of an earlier success.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReconcileResponse" } } } }, "400": { "description": "`idempotency_key_required` (the `Idempotency-Key` header is missing or longer than 255 characters) or `invalid_request` (the body is not JSON with string `from` and `into` of at most 255 characters, or `reason` is longer than 500).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "idempotency_key_required", "message": "Send an Idempotency-Key header of 1 to 255 characters." } } } } }, "401": { "description": "`secret_key_required`: no key, a publishable key, or a secret key that is not valid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "secret_key_required", "message": "That secret key is not valid." } } } } }, "409": { "description": "`reconcile_refused`. Nothing moved. The specific reason is on the customer page in the dashboard, not in this response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "reconcile_refused", "message": "The ids could not be reconciled. See the dashboard for details." } } } } }, "422": { "description": "`idempotency_mismatch`. The `Idempotency-Key` is already bound to different ids.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "idempotency_mismatch", "message": "This Idempotency-Key was already used with different ids." } } } } }, "429": { "description": "`rate_limited`. Too many reconcile calls.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "rate_limited", "message": "Too many reconcile calls. Retry later." } } } }, "headers": { "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } } } } } } }, "/identities/link-token/": { "post": { "operationId": "createIdentityLinkToken", "summary": "Create Link Token", "tags": [ "Identity" ], "security": [ { "SecretKeyAuth": [] } ], "description": "Mints a short-lived link token for an app to present with its publishable key (see Present Link Token). Use it when you want the app to finish the move instead of calling Reconcile Identities yourself.\n\nAuthenticated with your **secret key** (`zs_sk_*`). The token names one `from` and one `into`, expires after 10 minutes, and can be used once. Minting does not check the ids: the checks happen when the token is presented. You can also sign tokens yourself with no network call, see the Guest to Account guide for the exact format.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "from", "into" ], "properties": { "from": { "type": "string", "maxLength": 255, "description": "The id to merge away, usually the guest id. Ids are matched without regard to letter case." }, "into": { "type": "string", "maxLength": 255, "description": "The id that keeps the purchases, usually the account id." } } } } } }, "responses": { "200": { "description": "The token.", "content": { "application/json": { "schema": { "type": "object", "properties": { "token": { "type": "string", "description": "The signed link token. Pass it to the SDK's `reconcile(linkToken:)`." }, "expires_at": { "type": "string", "format": "date-time", "description": "When the token expires." } } } } } }, "400": { "description": "`invalid_request`. The body is not JSON with string `from` and `into` of at most 255 characters.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "invalid_request", "message": "Send a JSON body with \"from\" and \"into\" ids of at most 255 characters." } } } } }, "401": { "description": "`secret_key_required`: no key, a publishable key, or a secret key that is not valid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "secret_key_required", "message": "That secret key is not valid." } } } } } } } }, "/iap/identities/reconcile/": { "post": { "operationId": "presentIdentityLinkToken", "summary": "Present Link Token", "tags": [ "Identity" ], "description": "Called by the SDK (`reconcile(linkToken:)`) to finish a guest-to-account move with a link token your server signed. Authenticated with the publishable key.\n\nThe token is single use. It is spent when presented, even if the reconcile is refused. A `429` does not spend it. Limits: the project reconcile limits (300 a minute and 50,000 a day, counted apart from the secret-key endpoint), and 5 presentations an hour for the same `from` id. That per-id limit fails closed: if the rate-limit store is unavailable the call returns `429` with `Retry-After: 5`.\n\nA merge sends an `identity.reconciled` webhook with `actor` of `link_token`.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "link_token" ], "properties": { "link_token": { "type": "string", "description": "The token from Create Link Token or your own signing." } } } } } }, "responses": { "200": { "description": "The ids were reconciled.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReconcileResponse" } } } }, "400": { "description": "`invalid_request`. `link_token` is missing or not a string.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "invalid_request", "message": "Send a JSON body with a \"link_token\" string." } } } } }, "401": { "description": "`invalid_link_token`: the token is malformed, has a bad signature, is expired, was already used, or is for the other environment. An invalid publishable key is also `401`, in the standard error shape.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "invalid_link_token", "message": "That link token is not valid." } } } } }, "409": { "description": "`reconcile_refused`. Nothing moved and the token is spent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "reconcile_refused", "message": "The ids could not be reconciled. See the dashboard for details." } } } } }, "429": { "description": "`rate_limited`. The token is not spent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "rate_limited", "message": "Too many presentations for this id. Retry later." } } } }, "headers": { "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } } } } } } }, "/iap/identity-link/": { "post": { "operationId": "linkIdentityRows", "summary": "Link Identity Rows", "tags": [ "Identity" ], "description": "Moves a guest id's experiment and analytics rows to the signed-in id, from the app, with the publishable key. Use it to keep a pre-login funnel and experiment assignment when the person logs in.\n\n**Without `link_token`** this endpoint never moves purchases and never touches a customer record. It moves experiment impressions, assignments, exposures, outcome rows and funnel events from `previous_user_id` (or `previous_install_id`) to `user_id`. It skips an id that a live account owns, and an id that was already merged away, and answers `status: \"skipped\"` with zero counts. Where `user_id` already holds a row for the same experiment, that row wins and the guest's row is left in place. Nothing is dropped.\n\n**With `link_token`** it is a full reconcile, the same as Present Link Token: purchases move too, the same limits and refusals apply, and the response also carries `reconciliation_id`. Ids sent beside a token must both be present and match the token, or neither.\n\nWithout a token it is rate limited to 60 calls a minute per client IP and 6,000 a minute per app. Over a limit the response is `429` with `Retry-After`.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "previous_user_id": { "type": "string", "description": "The guest id to move rows from. Send this or `previous_install_id`." }, "previous_install_id": { "type": "string", "description": "The raw SDK install id, as an alternative to `previous_user_id`." }, "user_id": { "type": "string", "description": "The signed-in id to move rows to." }, "link_token": { "type": "string", "description": "Optional. A link token from Create Link Token. With it, this is a full reconcile." } } }, "example": { "previous_user_id": "3f1c9c52-8f5e-4a52-9d9c-0f0b6f1a7c11", "user_id": "user_8472" } } } }, "responses": { "200": { "description": "Processed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "ok", "skipped" ], "description": "`skipped` when the guest id belongs to a live account or was merged away and no token was sent. Counts are then zero." }, "impressions_moved": { "type": "integer" }, "impressions_dropped": { "type": "integer" }, "assignments_moved": { "type": "integer" }, "assignments_dropped": { "type": "integer" }, "sticky_assignments_moved": { "type": "integer" }, "sticky_assignments_dropped": { "type": "integer" }, "sticky_assignments_collided": { "type": "integer" }, "sticky_assignments_protected": { "type": "integer" }, "exposures_moved": { "type": "integer" }, "exposures_dropped": { "type": "integer" }, "ledger_rows_moved": { "type": "integer" }, "funnel_events_moved": { "type": "integer" }, "reconciliation_id": { "type": "integer", "description": "Present only when a `link_token` was sent." } } }, "example": { "status": "ok", "impressions_moved": 2, "impressions_dropped": 0, "assignments_moved": 1, "assignments_dropped": 0, "sticky_assignments_moved": 1, "sticky_assignments_dropped": 0, "sticky_assignments_collided": 0, "sticky_assignments_protected": 0, "exposures_moved": 2, "exposures_dropped": 0, "ledger_rows_moved": 0, "funnel_events_moved": 5 } } } }, "400": { "description": "`user_id` and one of `previous_user_id` or `previous_install_id` are required, or the body is not valid JSON.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "`invalid_link_token`, when a `link_token` was sent and is not valid. An invalid publishable key is also `401`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "invalid_link_token", "message": "That link token is not valid." } } } } }, "409": { "description": "`reconcile_refused`. Only with a `link_token`: nothing moved and the token is spent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "reconcile_refused", "message": "The ids could not be reconciled. See the dashboard for details." } } } } }, "429": { "description": "`rate_limited`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityError" }, "example": { "error": { "code": "rate_limited", "message": "Too many identity links. Retry later." } } } }, "headers": { "Retry-After": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } } } } } } } }, "components": { "securitySchemes": { "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "X-ZeroSettle-Key", "description": "Your publishable API key. Use `zs_pk_test_*` for sandbox or `zs_pk_live_*` for production." }, "SecretKeyAuth": { "type": "apiKey", "in": "header", "name": "X-ZeroSettle-Key", "description": "Your secret API key (`zs_sk_test_*` or `zs_sk_live_*`). Server-side only: never ship a secret key in an app. Only the endpoints that say so accept it." } }, "schemas": { "Product": { "type": "object", "description": "A product in the catalog.", "properties": { "id": { "type": "string", "description": "The product's reference ID (e.g., `com.app.premium.monthly`)." }, "display_name": { "type": "string", "description": "Human-readable product name." }, "product_description": { "type": "string", "description": "Product description text." }, "type": { "type": "string", "enum": [ "consumable", "non_consumable", "non_renewing_subscription", "auto_renewable_subscription" ], "description": "The product type." }, "web_price": { "description": "The web checkout price, or null if not available for web purchase.", "allOf": [ { "$ref": "#/components/schemas/Price" } ], "nullable": true }, "storekit_price": { "description": "The StoreKit price from App Store Connect, or null if not synced.", "allOf": [ { "$ref": "#/components/schemas/Price" } ], "nullable": true }, "subscription_group_id": { "description": "ID of the subscription family this product belongs to, or null for non-subscription products.", "type": "integer", "nullable": true }, "free_trial_duration": { "description": "Free trial length as a ZeroSettle duration token (for example `1_week`, `3_days`, `1_month`), or null if no trial is configured. This is not an ISO 8601 duration.", "type": "string", "nullable": true }, "is_trial_eligible": { "description": "Whether the user is eligible for a free trial on this product. Null when `user_id` was not sent, or when the product has no trial or is not in a subscription family.", "type": "boolean", "nullable": true }, "trial": { "description": "Trial mode details. Present for `auto_renewable_subscription` products that have a free trial configured whenever `user_id` is sent, whether or not the user is trial-eligible (check `is_trial_eligible` for that). Absent otherwise.", "nullable": true, "type": "object", "properties": { "mode": { "type": "string", "enum": [ "free", "paid", "auth_hold" ], "description": "How the trial is collected. `free`: no charge during the trial period; the payment method is saved and billed at trial end. `paid`: a real upfront charge of `upfront_amount_cents` is collected immediately and kept; the full subscription price is billed at trial end. `auth_hold`: a temporary authorization hold of `hold_amount_cents` is placed (shown as a pending charge); released when the subscription converts or is cancelled." }, "duration": { "type": "string", "description": "Duration token for the trial period (for example `1_week`, `3_days`, `1_month`), the same value as `free_trial_duration`. This is a ZeroSettle token, not an ISO 8601 duration." }, "upfront_amount_cents": { "type": "integer", "description": "For `paid` mode: the real charge collected at checkout, in cents. Zero for `free` and `auth_hold` modes." }, "hold_amount_cents": { "type": "integer", "description": "For `auth_hold` mode: the authorization hold amount placed at checkout, in cents. Zero for `free` and `paid` modes." }, "validates_card": { "type": "boolean", "description": "`true` for `paid` and `auth_hold` modes (a real or pending charge verifies the card); `false` for `free` mode." } } }, "storekit_product_id": { "type": "string", "description": "The App Store Connect product identifier to use for StoreKit purchases, when it differs from `id`.", "nullable": true }, "synced_to_asc": { "type": "boolean", "description": "Whether the product has been synced to App Store Connect." }, "play_store_price": { "description": "The Google Play price, or null if the product has no active Play link.", "allOf": [ { "$ref": "#/components/schemas/Price" } ], "nullable": true }, "play_product_id": { "type": "string", "description": "The Google Play product ID, or null.", "nullable": true }, "play_base_plan_id": { "type": "string", "description": "The Google Play base plan ID for subscriptions, or null.", "nullable": true }, "regional_pricing": { "type": "object", "nullable": true, "description": "Present when regional pricing is configured for the buyer. When `applied` is true, `web_price` already reflects the regional amount.", "properties": { "applied": { "type": "boolean" }, "base_price_cents": { "type": "integer", "description": "The un-regionalized list price in cents." }, "tier": { "description": "Regional pricing tier ID." }, "country": { "type": "string" } } }, "promotion": { "nullable": true, "description": "Reserved. Currently always null." }, "family_id": { "type": "integer", "nullable": true, "description": "ID of the product family the product belongs to, or null." }, "family_name": { "type": "string", "description": "Name of the product family.", "nullable": true }, "family_kind": { "type": "string", "description": "Kind of product family (for example `subscription`).", "nullable": true }, "billing_interval": { "type": "string", "description": "Billing interval for recurring products, or null.", "nullable": true }, "checkout_route": { "type": "string", "enum": [ "web", "store" ], "description": "Where the SDK should route this purchase. `web` is the default. `store` means a routing experiment assigned this user to the store checkout, so do not offer web checkout." } } }, "Price": { "type": "object", "description": "A price in micros (1/1,000,000 of the currency unit).", "properties": { "amount_micros": { "type": "integer", "description": "Price in micros. For example, $4.99 = 4990000." }, "currency_code": { "type": "string", "description": "ISO 4217 currency code (e.g., `USD`)." } } }, "CheckoutConfig": { "type": "object", "description": "Configuration for how the SDK should present checkout.", "properties": { "sheet_type": { "type": "string", "enum": [ "webview", "safari_vc", "safari", "native_pay" ], "description": "Default checkout presentation (the US configuration, kept for older SDKs). `webview` is the default and the supported choice. `native_pay` is not supported for new integrations." }, "is_enabled": { "type": "boolean", "description": "Whether web checkout is enabled for this app." }, "jurisdictions": { "type": "object", "description": "Per-jurisdiction checkout overrides.", "properties": { "us": { "$ref": "#/components/schemas/JurisdictionConfig" }, "eu": { "$ref": "#/components/schemas/JurisdictionConfig" }, "other": { "$ref": "#/components/schemas/JurisdictionConfig" } } }, "apple_merchant_id": { "description": "Apple Merchant ID for Apple Pay, if configured.", "type": "string", "nullable": true } } }, "JurisdictionConfig": { "type": "object", "description": "Checkout configuration for a specific jurisdiction.", "properties": { "sheet_type": { "type": "string", "enum": [ "webview", "safari_vc", "safari", "native_pay" ], "description": "Checkout presentation for this jurisdiction. `webview` is the default and the supported choice. `native_pay` is not supported for new integrations." }, "is_enabled": { "type": "boolean", "description": "Whether web checkout is enabled in this jurisdiction." } } }, "MigrationCampaign": { "type": "object", "description": "An active Switch & Save migration campaign.", "properties": { "should_show": { "type": "boolean", "description": "Whether the migration offer should be displayed to the user." }, "product_id": { "type": "string", "description": "The product the migration offer applies to." }, "discount_percent": { "type": "integer", "description": "Discount percentage offered (e.g., 20 for 20% off)." }, "min_subscription_days": { "type": "integer", "description": "Minimum number of days the user must have been subscribed to be eligible." }, "max_subscription_days": { "type": "integer", "description": "Maximum number of subscription days for eligibility." }, "free_trial_days": { "type": "integer", "description": "Number of free trial days offered in the migration." }, "title": { "type": "string", "description": "Title text for the migration offer UI." }, "message": { "type": "string", "description": "Body text for the migration offer UI." }, "variant_id": { "description": "Experiment variant ID if the campaign is part of an A/B test.", "type": "integer", "nullable": true } } }, "PaymentIntentResponse": { "type": "object", "description": "Response from creating a payment intent.", "properties": { "client_secret": { "type": "string", "description": "Stripe client secret. Confirm the intent named by `intent_type` with it." }, "transaction_id": { "type": "string", "description": "ZeroSettle transaction ID. Use this to poll for transaction status." }, "amount": { "type": "integer", "description": "The full amount in cents, or the recurring price for a subscription. See `charge_amount` for what the intent charges or holds now." }, "original_amount": { "type": "integer", "description": "The original price in cents before discounts, trials or prorations." }, "charge_amount": { "type": "integer", "description": "Cents the intent charges or holds now. Present for trial checkouts. Differs from `amount` for paid and auth-hold trials." }, "currency": { "type": "string", "description": "ISO 4217 currency code (for example `usd`)." }, "product_name": { "type": "string", "description": "Display name of the product being purchased." }, "checkout_url": { "type": "string", "description": "URL of the hosted checkout page. The client secret is in the URL fragment." }, "callback_url": { "type": "string", "description": "URL the checkout page returns to after payment." }, "publishable_key": { "type": "string", "description": "Stripe publishable key to use with `client_secret`." }, "app_id": { "type": "integer", "description": "Project ID. Same value as `project_id`; kept for older SDKs." }, "project_id": { "type": "integer", "description": "Project ID." }, "stripe_account": { "type": "string", "description": "Connected Stripe account ID for BYOS projects. Omitted for ZeroSettle-managed Stripe." }, "merchant_country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code of the merchant Stripe account. Omitted when unknown." }, "is_subscription": { "type": "boolean", "description": "`true` for subscriptions. Omitted for one-time purchases." }, "subscription_interval": { "type": "string", "description": "Billing interval (for example `month`, `week`). Present for subscriptions." }, "stripe_subscription_id": { "type": "string", "description": "Stripe subscription ID. Present for subscription checkouts." }, "trial_type": { "type": "string", "enum": [ "free_trial", "migration" ], "description": "Type of trial applied. `free_trial` for standard trials, `migration` for Switch & Save aligned trials. Omitted when no trial is active." }, "trial_end": { "type": "integer", "description": "Unix timestamp when the trial ends. Only present when a trial is active." }, "pending_amount": { "type": "integer", "description": "Cents billed when the trial ends. Only present when a trial is active." }, "intent_type": { "type": "string", "enum": [ "setup", "payment" ], "description": "Which Stripe confirm method the client must call. `setup` confirms a SetupIntent (free trial, card saved, no immediate charge). `payment` confirms a PaymentIntent (paid or auth-hold trial, or a normal charge)." }, "checkout_presentation": { "type": "string", "description": "How the SDK should present checkout, from your dashboard checkout default. Translated per platform using `X-ZS-SDK-Platform`." } }, "required": [ "client_secret", "transaction_id", "amount", "currency", "checkout_url" ] }, "CheckoutSessionResponse": { "type": "object", "description": "Response from creating a checkout session.", "properties": { "session_id": { "type": "string", "description": "Stripe Checkout Session ID." }, "checkout_url": { "type": "string", "description": "Stripe-hosted URL to send the user to." }, "transaction_id": { "type": "string", "description": "ZeroSettle transaction ID." }, "checkout_presentation": { "type": "string", "description": "How the SDK should present checkout, from your dashboard checkout default. Translated per platform using `X-ZS-SDK-Platform`." } }, "required": [ "session_id", "checkout_url", "transaction_id" ] }, "Transaction": { "type": "object", "description": "A purchase transaction.", "properties": { "id": { "type": "string", "description": "Transaction ID (UUID)." }, "product_id": { "type": "string", "description": "The product's reference ID." }, "status": { "type": "string", "enum": [ "pending", "processing", "completed", "failed", "refunded", "superseded" ], "description": "Transaction status as sent to SDKs. A successful payment is `completed`. A disputed transaction is reported as `refunded`. `superseded` is an abandoned checkout that a newer checkout for the same product replaced." }, "source": { "type": "string", "enum": [ "web_checkout", "store_kit", "play_store" ], "description": "Where the transaction originated. `play_store` is sent only to Android clients (`X-ZS-SDK-Platform: android`); other clients get `web_checkout` for Google Play transactions." }, "purchased_at": { "type": "string", "format": "date-time", "description": "ISO 8601 timestamp of when the purchase was made." }, "storekit_status": { "description": "StoreKit finish status: `1` = active (finished), `2` = anything-not-active (cancelled, expired, billing retry, grace period, revoked). Null for web checkout transactions.", "type": "integer", "nullable": true }, "expires_at": { "description": "ISO 8601 expiry timestamp of the entitlement this transaction created, for subscriptions. Omitted (not null) when there is none.", "type": "string", "format": "date-time" } }, "required": [ "id", "product_id", "status", "source", "purchased_at" ] }, "TransactionHistoryItem": { "type": "object", "description": "A transaction in the user's history.", "properties": { "id": { "type": "string", "description": "Transaction ID." }, "product_id": { "type": "string", "description": "The product's reference ID." }, "status": { "type": "string", "enum": [ "completed", "pending", "processing", "failed", "refunded" ], "description": "Transaction status as sent to SDKs. A successful payment is `completed`. A disputed transaction is reported as `refunded`. Abandoned checkout sessions are not returned." }, "source": { "type": "string", "description": "Transaction source: `web_checkout`, `store_kit`, or another raw source value such as `play_billing`." }, "purchased_at": { "type": "string", "format": "date-time", "description": "Purchase timestamp." }, "amount_cents": { "type": "integer", "description": "Transaction amount in cents." }, "currency": { "type": "string", "description": "ISO 4217 currency code." }, "product_name": { "type": "string", "description": "Display name of the product." }, "expires_at": { "description": "Always null. Expiry is tracked on entitlements, not transactions: use Get Entitlements.", "type": "string", "format": "date-time", "nullable": true }, "storekit_status": { "description": "StoreKit finish status: `1` = active, `2` = anything-not-active. Null for web checkout transactions.", "type": "integer", "nullable": true } } }, "Entitlement": { "type": "object", "description": "A user's entitlement to a product.", "properties": { "id": { "type": "string", "description": "Stable transaction-derived identifier in the form `txn_`. Older entitlements may still return the legacy `txn_` format." }, "product_id": { "type": "string", "description": "The product's reference ID." }, "product_type": { "type": "string", "enum": [ "consumable", "non_consumable", "non_renewing", "auto_renewable", "subscription" ], "description": "The product type as stored: `consumable`, `non_consumable`, `non_renewing` or `auto_renewable`. Some older rows carry the legacy value `subscription`." }, "source": { "type": "string", "enum": [ "web_checkout", "store_kit", "play_store" ], "description": "Where the entitlement currently comes from, using the precedence web checkout, then StoreKit, then Play. `play_store` is sent only to Android clients (`X-ZS-SDK-Platform: android`); other clients get `web_checkout` for Google Play entitlements." }, "status": { "type": "string", "enum": [ "active", "cancelled", "grace_period", "past_due" ], "description": "Current status. Only entitlements that still grant access are returned, so this is one of `active`, `cancelled` (auto-renew is off but the paid period has not ended), `grace_period` or `past_due`." }, "is_active": { "type": "boolean", "description": "Whether the user currently has access to this product." }, "expires_at": { "description": "When the entitlement expires, or null for lifetime purchases and consumables.", "type": "string", "format": "date-time", "nullable": true }, "purchased_at": { "type": "string", "format": "date-time", "description": "When the entitlement was originally purchased.", "nullable": true }, "will_renew": { "type": "boolean", "description": "Whether the subscription will auto-renew at the end of the current period." }, "is_trial": { "type": "boolean", "description": "Whether the entitlement is currently in a free trial period." }, "trial_ends_at": { "description": "When the trial period ends, or null if not on trial.", "type": "string", "format": "date-time", "nullable": true }, "cancelled_at": { "description": "When the subscription was cancelled, or null if not cancelled.", "type": "string", "format": "date-time", "nullable": true }, "is_test_purchase": { "type": "boolean", "description": "`true` when Apple processed this as a sandbox purchase (TestFlight, App Review or Xcode) but your app sent it with a live key. It stays in Live, still grants access, and is left out of revenue. `false` otherwise." }, "grace_period_ends_at": { "type": "string", "format": "date-time", "description": "End of the billing grace period. Present only while in grace." }, "paused_at": { "type": "string", "format": "date-time", "description": "When the subscription was paused. Present only when paused." }, "pause_resumes_at": { "type": "string", "format": "date-time", "description": "When a paused subscription resumes. Present only when paused." }, "subscription_group_id": { "type": "string", "description": "ID of the subscription family, as a string. Present for subscription products." }, "family_id": { "type": "integer", "description": "ID of the product family. Present when the product belongs to one." }, "family_name": { "type": "string", "description": "Name of the product family.", "nullable": true }, "family_kind": { "type": "string", "description": "Kind of product family.", "nullable": true } }, "required": [ "id", "product_id", "product_type", "source", "status", "is_active" ] }, "StorekitSubscriptionStatus": { "type": "object", "description": "Status of a StoreKit subscription. Prefer the normalized `is_active` and `auto_renew_enabled` booleans for canonical access decisions; `status` and `auto_renew_status` are diagnostic fields that mirror the raw Apple payload.", "required": [ "is_active", "auto_renew_enabled" ], "properties": { "status": { "type": "integer", "description": "Diagnostic only — SDK-collapsed Apple status: 1 (active) or 2 (not active). Prefer `is_active` for access decisions." }, "auto_renew_status": { "type": "integer", "description": "Diagnostic only — raw Apple `autoRenewStatus` integer (0 = off, 1 = on). Prefer `auto_renew_enabled` for canonical use." }, "expires_at": { "type": "string", "format": "date-time", "nullable": true, "description": "When the subscription expires or renews." }, "is_active": { "type": "boolean", "description": "Canonical active-entitlement flag. `true` when the Apple status code represents active access (active, grace period, or billing retry with access). Derived server-side from `apple_status` — use this instead of interpreting `status` yourself." }, "auto_renew_enabled": { "type": "boolean", "description": "Canonical auto-renew flag. Equivalent to `auto_renew_status == 1`, exposed as a boolean for consistency with the rest of the API." } } }, "CancelResponse": { "type": "object", "description": "Response from cancelling a subscription.", "properties": { "success": { "type": "boolean", "description": "Always `true`." }, "status": { "type": "string", "description": "The entitlement status after the call: `cancelled` when it cancels at the end of the billing period, `expired` when it cancels immediately." }, "cancelled_at": { "type": "string", "description": "When the cancellation was processed.", "nullable": true, "format": "date-time" } } }, "PauseResponse": { "type": "object", "description": "Response from pausing a subscription.", "properties": { "success": { "type": "boolean", "description": "Always `true`." }, "status": { "type": "string", "description": "Always `paused`." }, "paused_at": { "type": "string", "description": "When the subscription was paused.", "nullable": true, "format": "date-time" }, "resumes_at": { "type": "string", "description": "When the subscription resumes automatically. Null for an indefinite pause.", "nullable": true, "format": "date-time" } } }, "ResumeResponse": { "type": "object", "description": "Response from resuming a subscription.", "properties": { "success": { "type": "boolean", "description": "Always `true`." }, "status": { "type": "string", "description": "Always `active`." } } }, "CancelFlow": { "type": "object", "description": "Cancel flow configuration.", "properties": { "enabled": { "type": "boolean", "description": "`false` when no cancel flow is active. When `false`, no other field is sent." }, "questions": { "type": "array", "description": "Survey questions.", "items": { "$ref": "#/components/schemas/CancelFlowQuestion" } }, "offer": { "description": "Retention offer, or null if none is enabled.", "allOf": [ { "$ref": "#/components/schemas/CancelFlowOffer" } ], "nullable": true }, "pause": { "description": "Pause option, or null if pause is not enabled.", "allOf": [ { "$ref": "#/components/schemas/CancelFlowPause" } ], "nullable": true }, "deflect": { "type": "object", "nullable": true, "description": "Deflection block, or null. Sent only to SDKs on the full shape (ZeroSettleKit 1.6.0 and later on iOS; a later release for Android and Flutter).", "properties": { "enabled": { "type": "boolean" }, "title": { "type": "string" }, "body": { "type": "string" }, "items": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "order": { "type": "integer" }, "label": { "type": "string" }, "action_type": { "type": "string" }, "action_value": { "type": "string" } } } } } }, "downgrade": { "type": "object", "nullable": true, "description": "Downgrade block, or null. Sent only to SDKs on the full shape.", "properties": { "enabled": { "type": "boolean" }, "title": { "type": "string" }, "body": { "type": "string" }, "cta_text": { "type": "string" }, "target_product_id": { "type": "string" } } }, "variant_id": { "type": "integer", "description": "Experiment variant ID when the cancel flow is part of an A/B test.", "nullable": true } }, "required": [ "enabled" ] }, "CancelFlowQuestion": { "type": "object", "description": "A survey question in the cancel flow.", "properties": { "id": { "type": "integer", "description": "Question ID. Send it back in the `answers` of Submit Cancel Flow Response." }, "order": { "type": "integer", "description": "Display order." }, "question_text": { "type": "string", "description": "The question to show." }, "question_type": { "type": "string", "description": "The question type, for example single choice or free text." }, "is_required": { "type": "boolean", "description": "Whether the user must answer." }, "options": { "type": "array", "description": "Answer options. Objects on every SDK except legacy Android (before 1.3.0), which receives plain label strings.", "items": { "oneOf": [ { "$ref": "#/components/schemas/CancelFlowOption" }, { "type": "string" } ] } } } }, "CancelFlowOffer": { "type": "object", "description": "A retention offer in the cancel flow.", "properties": { "enabled": { "type": "boolean", "description": "Always `true` when the object is present." }, "title": { "type": "string" }, "body": { "type": "string" }, "cta_text": { "type": "string", "description": "Button text." }, "type": { "type": "string", "description": "Offer type: a percentage discount or a free period." }, "value": { "type": "string", "description": "Offer value: the percent for a discount, or the length for a free period." }, "duration": { "type": "integer", "description": "How long the offer lasts, in `duration_unit`. For a free period this is the free length.", "nullable": true }, "duration_unit": { "type": "string", "description": "Unit for `duration`. Sent only on the full shape." } } }, "CancelFlowPause": { "type": "object", "description": "Pause option in the cancel flow.", "properties": { "enabled": { "type": "boolean", "description": "Always `true` when the object is present." }, "title": { "type": "string" }, "body": { "type": "string" }, "cta_text": { "type": "string", "description": "Button text." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "order": { "type": "integer" }, "label": { "type": "string" }, "duration_type": { "type": "string", "description": "How the pause length is defined." }, "duration_days": { "type": "integer", "description": "Pause length in days.", "nullable": true }, "resume_date": { "type": "string", "description": "Fixed resume date.", "nullable": true, "format": "date" } } } } } }, "UpgradeOffer": { "type": "object", "description": "Upgrade offer for a user. When `available` is false only `reason` (and possibly `variant_id`) is sent.", "required": [ "available" ], "properties": { "available": { "type": "boolean", "description": "Whether an upgrade offer exists for the user." }, "reason": { "type": "string", "description": "Why no offer is available. Present when `available` is false. Values include `no_active_subscription`, `subscription_not_active`, `upgrade_offer_disabled`, `too_early`, `no_upgrade_path` and `no_price_data`." }, "current_product": { "type": "object", "properties": { "reference_id": { "type": "string" }, "name": { "type": "string" }, "price_cents": { "type": "integer" }, "currency": { "type": "string" }, "duration_days": { "type": "integer" }, "billing_label": { "type": "string", "description": "`weekly`, `monthly`, `quarterly`, `semi-annual` or `yearly`." } }, "description": "The user's current plan. Present when available." }, "target_product": { "type": "object", "properties": { "reference_id": { "type": "string" }, "name": { "type": "string" }, "price_cents": { "type": "integer" }, "currency": { "type": "string" }, "duration_days": { "type": "integer" }, "billing_label": { "type": "string" }, "monthly_equivalent_cents": { "type": "integer" } }, "description": "The plan being offered. Present when available." }, "savings_percent": { "type": "integer", "description": "Annualized saving of the target plan over the current one." }, "upgrade_type": { "type": "string", "enum": [ "storekit_to_web", "web_to_web" ], "description": "`web_to_web` modifies an existing web subscription. `storekit_to_web` moves an App Store subscriber to web checkout." }, "proration": { "type": "object", "nullable": true, "description": "Proration preview. Present for `web_to_web`; null when it could not be computed.", "properties": { "proration_amount_cents": { "type": "integer" }, "currency": { "type": "string" }, "next_billing_date": { "type": "integer", "nullable": true, "description": "Unix timestamp of the next billing attempt." } } }, "display": { "type": "object", "description": "Copy for the offer card from your dashboard settings.", "properties": { "title": { "type": "string" }, "body": { "type": "string" }, "cta_text": { "type": "string" }, "dismiss_text": { "type": "string" }, "storekit_migration_body": { "type": "string", "description": "Only for `storekit_to_web`." }, "storekit_cancel_instructions": { "type": "string", "description": "Only for `storekit_to_web`." } } }, "checkout_presentation": { "type": "string", "description": "How the SDK presents checkout, when configured." }, "variant_id": { "type": "integer", "description": "Experiment variant ID when the offer is part of an A/B test.", "nullable": true } } }, "UpgradeExecuteResponse": { "type": "object", "description": "Result of a web to web upgrade.", "required": [ "success", "upgrade_type" ], "properties": { "success": { "type": "boolean", "description": "Always `true`." }, "upgrade_type": { "type": "string", "enum": [ "web_to_web" ] }, "entitlement": { "type": "object", "properties": { "product_id": { "type": "string" }, "status": { "type": "string" }, "is_active": { "type": "boolean" }, "expires_at": { "type": "string", "description": "When the entitlement expires.", "nullable": true, "format": "date-time" } }, "description": "The entitlement after the change." } } }, "Offer": { "type": "object", "description": "Unified offer payload for migration and upgrade flows.", "required": [ "flow_type", "product_id", "eligible_product_ids", "savings_percent", "display", "free_trial_days", "min_subscription_days", "max_subscription_days", "rollout_percent" ], "properties": { "flow_type": { "type": "string", "enum": [ "migration", "upgrade" ], "description": "The type of offer flow." }, "product_id": { "type": "string", "description": "Target product reference ID." }, "eligible_product_ids": { "type": "array", "items": { "type": "string" }, "description": "Product IDs the user might currently have." }, "savings_percent": { "type": "integer", "description": "Annualized savings percentage (0 if not computable)." }, "display": { "$ref": "#/components/schemas/OfferDisplay" }, "free_trial_days": { "type": "integer", "description": "Free trial days (computed at checkout time from product config)." }, "min_subscription_days": { "type": "integer", "description": "Minimum days subscribed before eligibility." }, "max_subscription_days": { "type": "integer", "nullable": true, "description": "Maximum days subscribed for eligibility." }, "rollout_percent": { "type": "integer", "description": "Percentage of eligible users who see this offer (0-100)." }, "upgrade_type": { "type": "string", "enum": [ "storekit_to_web", "web_to_web" ], "nullable": true, "description": "Upgrade subtype. Only present when flow_type is upgrade." }, "from_product_id": { "type": "string", "nullable": true, "description": "User's current product (upgrades only)." }, "to_product_id": { "type": "string", "nullable": true, "description": "Upgrade target product (upgrades only)." }, "variant_id": { "type": "integer", "nullable": true, "description": "Experiment variant ID (when A/B testing)." }, "per_product_prompts": { "type": "object", "nullable": true, "description": "Per-product display overrides keyed by product ID." }, "checkout_presentation": { "type": "string", "enum": [ "inline", "sheet" ], "nullable": true, "description": "How the SDK presents checkout: inline within tip card or overlay sheet." } } }, "OfferDisplay": { "type": "object", "description": "Server-configurable copy for every tip card state. Empty strings mean the SDK should use its hardcoded defaults.", "required": [ "offer_title", "offer_message", "offer_cta", "accepted_title", "accepted_message", "accepted_cta", "completed_title", "completed_message" ], "properties": { "offer_title": { "type": "string", "description": "Title for the offer card (e.g., 'Thanks for being with us!')." }, "offer_message": { "type": "string", "description": "Main offer body text." }, "offer_cta": { "type": "string", "description": "CTA button text (e.g., 'Save 20% Forever')." }, "accepted_title": { "type": "string", "description": "Post-checkout title (e.g., 'Thanks for switching!')." }, "accepted_message": { "type": "string", "description": "Post-checkout message with Apple cancel instructions." }, "accepted_cta": { "type": "string", "description": "Post-checkout CTA (e.g., 'Cancel Apple Billing')." }, "completed_title": { "type": "string", "description": "Success title (e.g., 'Congratulations!')." }, "completed_message": { "type": "string", "description": "Success message (e.g., 'You are now saving 20% forever.')." } } }, "UserOfferResponse": { "type": "object", "description": "Canonical response from `GET /iap/user-offer/`. Wraps the user's current subscription state (as a tagged union keyed by `subscription.type`) together with exactly one offer decision.", "required": [ "user_id", "app_id", "is_sandbox", "subscription", "offer", "server_time" ], "properties": { "user_id": { "type": "string", "description": "Echoes the `user_id` query parameter." }, "app_id": { "type": "integer", "description": "Internal ZeroSettle app ID that owns the publishable key." }, "is_sandbox": { "type": "boolean", "description": "Resolved environment — `true` when the request was authenticated with a sandbox publishable key." }, "subscription": { "$ref": "#/components/schemas/UserOfferSubscription" }, "offer": { "$ref": "#/components/schemas/OfferEligibility" }, "server_time": { "type": "string", "format": "date-time", "description": "Server-side timestamp at which the response was resolved (ISO 8601). Useful for client-side cache invalidation." } } }, "UserOfferSubscription": { "description": "Tagged-union describing the user's current subscription state. Discriminated by the `type` field. Exactly one variant is returned per request.", "oneOf": [ { "$ref": "#/components/schemas/SubscriptionStateNone" }, { "$ref": "#/components/schemas/SubscriptionStateActiveWeb" }, { "$ref": "#/components/schemas/SubscriptionStateActiveStorekit" }, { "$ref": "#/components/schemas/SubscriptionStateActivePlay" }, { "$ref": "#/components/schemas/SubscriptionStateMigrationTrial" }, { "$ref": "#/components/schemas/SubscriptionStateCancelledActive" } ], "discriminator": { "propertyName": "type", "mapping": { "none": "#/components/schemas/SubscriptionStateNone", "active_web": "#/components/schemas/SubscriptionStateActiveWeb", "active_storekit": "#/components/schemas/SubscriptionStateActiveStorekit", "migration_trial": "#/components/schemas/SubscriptionStateMigrationTrial", "cancelled_active": "#/components/schemas/SubscriptionStateCancelledActive", "active_play": "#/components/schemas/SubscriptionStateActivePlay" } } }, "SubscriptionStateNone": { "type": "object", "description": "User has no active subscription entitlement from any source.", "required": [ "type" ], "properties": { "type": { "type": "string", "enum": [ "none" ] } } }, "SubscriptionStateActiveWeb": { "type": "object", "description": "User has an active web-sourced subscription (may be in grace period, past due, or paused).", "required": [ "type", "product_id", "status", "is_trial", "will_renew", "stripe_subscription_id", "entitlement_id" ], "properties": { "type": { "type": "string", "enum": [ "active_web" ] }, "product_id": { "type": "string", "description": "Reference ID of the active product." }, "family_id": { "type": "integer", "nullable": true, "description": "ZeroSettle product family ID, if any." }, "status": { "type": "string", "description": "EntitlementStatus value (e.g., `active`, `grace_period`, `past_due`, `paused`, `cancelled`)." }, "expires_at": { "type": "string", "format": "date-time", "nullable": true, "description": "When the current period ends / next renewal is due." }, "trial_ends_at": { "type": "string", "format": "date-time", "nullable": true, "description": "Trial end timestamp when `is_trial` is `true`." }, "is_trial": { "type": "boolean" }, "will_renew": { "type": "boolean", "description": "Whether the subscription is set to auto-renew at period end." }, "stripe_subscription_id": { "type": "string" }, "entitlement_id": { "type": "integer" } } }, "SubscriptionStateActiveStorekit": { "type": "object", "description": "User has an active StoreKit-sourced subscription.", "required": [ "type", "product_id", "status", "expires_at", "is_trial", "will_renew", "storekit_original_transaction_id", "entitlement_id" ], "properties": { "type": { "type": "string", "enum": [ "active_storekit" ] }, "product_id": { "type": "string" }, "family_id": { "type": "integer", "nullable": true, "description": "ZeroSettle product family ID, if any." }, "status": { "type": "string", "description": "EntitlementStatus value." }, "expires_at": { "type": "string", "format": "date-time" }, "is_trial": { "type": "boolean" }, "trial_ends_at": { "type": "string", "format": "date-time", "nullable": true }, "will_renew": { "type": "boolean" }, "storekit_original_transaction_id": { "type": "string" }, "entitlement_id": { "type": "integer" } } }, "SubscriptionStateMigrationTrial": { "type": "object", "description": "Dual-active migration trial window: the user has a web subscription inside its free trial plus an overlapping StoreKit entitlement that has not yet expired.", "required": [ "type", "web_product_id", "storekit_product_id", "trial_ends_at", "storekit_expires_at", "stripe_subscription_id", "storekit_original_transaction_id", "entitlement_id" ], "properties": { "type": { "type": "string", "enum": [ "migration_trial" ] }, "web_product_id": { "type": "string" }, "family_id": { "type": "integer", "nullable": true, "description": "ZeroSettle product family ID, if any." }, "storekit_product_id": { "type": "string" }, "trial_ends_at": { "type": "string", "format": "date-time" }, "storekit_expires_at": { "type": "string", "format": "date-time" }, "stripe_subscription_id": { "type": "string" }, "storekit_original_transaction_id": { "type": "string" }, "entitlement_id": { "type": "integer" } } }, "SubscriptionStateCancelledActive": { "type": "object", "description": "Subscription is cancelled but access remains until `expires_at`.", "required": [ "type", "source", "product_id", "expires_at", "entitlement_id" ], "properties": { "type": { "type": "string", "enum": [ "cancelled_active" ] }, "source": { "type": "string", "description": "EntitlementSource (e.g., `web_checkout`, `store_kit`)." }, "product_id": { "type": "string" }, "family_id": { "type": "integer", "nullable": true, "description": "ZeroSettle product family ID, if any." }, "expires_at": { "type": "string", "format": "date-time" }, "stripe_subscription_id": { "type": "string", "nullable": true }, "storekit_original_transaction_id": { "type": "string", "nullable": true }, "entitlement_id": { "type": "integer" } } }, "OfferEligibility": { "type": "object", "description": "The offer decision. Exactly one `action_type` is returned per user per request. `is_eligible` is `false` iff `action_type` is `no_action`.", "required": [ "action_type", "is_eligible", "checkout_product_id", "savings_percent", "free_trial_days", "min_subscription_days", "requires_apple_cancel" ], "properties": { "action_type": { "type": "string", "enum": [ "no_action", "migrate_storekit_to_web", "migrate_play_to_web", "upgrade_storekit_to_web", "upgrade_web_to_web" ], "description": "Which offer branch fired. `no_action` = nothing to show." }, "is_eligible": { "type": "boolean", "description": "Convenience boolean; equivalent to `action_type != 'no_action'`." }, "checkout_product_id": { "type": "string", "description": "Product ID the SDK should drive checkout toward. Empty string when `action_type` is `no_action`." }, "from_product_id": { "type": "string", "nullable": true, "description": "The product the user is currently on (relevant for upgrade/migrate flows)." }, "savings_percent": { "type": "integer", "description": "Annualized savings percent vs. the user's current plan, 0 when not applicable." }, "free_trial_days": { "type": "integer", "description": "Days of free trial the SDK should honor on the new subscription." }, "min_subscription_days": { "type": "integer", "description": "Minimum subscribed days before the offer becomes eligible (for rollout/lock-in rules)." }, "display": { "allOf": [ { "$ref": "#/components/schemas/OfferDisplayV2" } ], "nullable": true }, "proration": { "allOf": [ { "$ref": "#/components/schemas/OfferProration" } ], "nullable": true }, "requires_apple_cancel": { "type": "boolean", "description": "Whether the user must cancel their App Store subscription after the new checkout completes (true for `migrate_storekit_to_web` / `upgrade_storekit_to_web`)." }, "apple_subscription": { "allOf": [ { "$ref": "#/components/schemas/AppleSubscriptionSummary" } ], "nullable": true }, "checkout_presentation": { "type": "string", "enum": [ "webview", "native_pay", "safari_vc", "safari" ], "nullable": true, "description": "How the SDK should present checkout for this offer. `webview` is the default. `native_pay` is a legacy value and is not supported for new integrations." }, "experiment_variant_id": { "type": "integer", "nullable": true, "description": "A/B experiment variant ID when this offer is part of an experiment." }, "max_subscription_days": { "type": "integer", "nullable": true, "description": "Maximum subscribed days for the offer to apply. Null for no limit." }, "rollout_percent": { "type": "integer", "description": "Percentage of eligible users who see the offer (0 to 100)." }, "source": { "type": "string", "description": "Storefront the user is migrating from (`store_kit` or `play_store`). Present only for `migrate_*` offers." } } }, "OfferDisplayV2": { "type": "object", "description": "Presentation copy for the user-offer card. Distinct from the legacy `OfferDisplay` schema used by `/iap/products/` offer config — field names are simplified and the copy is localized + interpolated server-side.", "required": [ "title", "body", "cta_text", "dismiss_text", "accepted_title", "accepted_body", "completed_title", "completed_body" ], "properties": { "title": { "type": "string" }, "body": { "type": "string" }, "cta_text": { "type": "string" }, "dismiss_text": { "type": "string" }, "accepted_title": { "type": "string" }, "accepted_body": { "type": "string" }, "completed_title": { "type": "string" }, "completed_body": { "type": "string" }, "apple_cancel_instructions": { "type": "string", "description": "Localized instructions shown in the accepted-state card when `requires_apple_cancel` is `true`. May be empty." } } }, "OfferProration": { "type": "object", "description": "Stripe proration preview for web-to-web upgrades. Present only when `action_type` is `upgrade_web_to_web`.", "required": [ "amount_cents", "currency" ], "properties": { "amount_cents": { "type": "integer", "description": "Credit applied from the old subscription, in minor currency units. Negative values represent money owed back to the user." }, "currency": { "type": "string", "description": "ISO-4217 currency code for the proration amount." }, "next_billing_date": { "type": "string", "format": "date-time", "nullable": true, "description": "When the customer will next be billed on the upgraded plan." } } }, "AppleSubscriptionSummary": { "type": "object", "description": "Normalized view of the user's Apple (StoreKit) subscription. Present only when the user currently has — or recently had — a StoreKit entitlement. Clients should rely on `is_active` rather than deriving activeness from `status_code`.", "required": [ "is_active", "status_code", "auto_renew_enabled" ], "properties": { "is_active": { "type": "boolean", "description": "Canonical active-entitlement flag, normalized server-side from the Apple status code." }, "expires_at": { "type": "string", "format": "date-time", "nullable": true }, "status_code": { "type": "integer", "description": "Raw Apple status code (1=active, 2=expired, 3=billing_retry, 4=grace_period, 5=revoked). Diagnostic only — prefer `is_active`." }, "auto_renew_enabled": { "type": "boolean" } } }, "Error": { "type": "object", "description": "Error response. `error` is always present. Many errors also carry a machine-readable `code`, and some use `error` itself as the code (for example `in_flight`, `use_checkout_configs`).", "properties": { "error": { "type": "string", "description": "Human-readable error message." }, "code": { "type": "string", "description": "Machine-readable error code (not always present)." }, "message": { "type": "string", "description": "Extra detail for some errors." }, "retry_after": { "type": "integer", "description": "Seconds to wait before retrying. Sent with `in_flight` and server-busy errors." } }, "required": [ "error" ] }, "CancelFlowOption": { "type": "object", "properties": { "id": { "type": "integer", "description": "Option ID." }, "order": { "type": "integer", "description": "Display order." }, "label": { "type": "string", "description": "Option text." }, "icon_name": { "type": "string", "description": "Icon name.", "nullable": true }, "subtitle": { "type": "string", "description": "Secondary text.", "nullable": true }, "triggers_offer": { "type": "boolean", "description": "Selecting this option shows the retention offer." }, "triggers_pause": { "type": "boolean", "description": "Selecting this option shows the pause option." }, "route": { "type": "string", "nullable": true, "description": "Where selecting this option leads. Sent only on the full shape (ZeroSettleKit 1.6.0 and later on iOS; a later release for Android and Flutter)." } }, "description": "A survey answer option." }, "UpgradeExecuteStorekitResponse": { "type": "object", "description": "Result for a StoreKit to web upgrade. Nothing has been charged yet.", "required": [ "success", "upgrade_type", "target_product_id" ], "properties": { "success": { "type": "boolean", "description": "Always `true`." }, "upgrade_type": { "type": "string", "enum": [ "storekit_to_web" ] }, "target_product_id": { "type": "string", "description": "Product to check out." }, "cancel_instructions": { "type": "string", "description": "Text telling the user to cancel the Apple subscription after upgrading." }, "metadata": { "type": "object", "description": "Values for the checkout to carry so the upgrade is tracked.", "additionalProperties": { "type": "string" } } } }, "SubscriptionStateActivePlay": { "type": "object", "description": "User has an active Google Play subscription (may be in grace period).", "required": [ "type", "product_id", "status", "is_trial", "will_renew", "entitlement_id" ], "properties": { "type": { "type": "string", "enum": [ "active_play" ] }, "product_id": { "type": "string" }, "family_id": { "type": "integer", "nullable": true, "description": "ZeroSettle product family ID, if any." }, "status": { "type": "string" }, "expires_at": { "type": "string", "format": "date-time", "nullable": true }, "is_trial": { "type": "boolean" }, "trial_ends_at": { "type": "string", "format": "date-time", "nullable": true }, "will_renew": { "type": "boolean" }, "play_purchase_token": { "type": "string" }, "play_subscription_id": { "type": "string" }, "play_linked_purchase_token": { "type": "string" }, "entitlement_id": { "type": "integer" } } }, "IdentityError": { "type": "object", "description": "The error shape of the identity endpoints.", "properties": { "error": { "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } } } } }, "ReconcileResponse": { "type": "object", "properties": { "reconciliation_id": { "type": "integer", "description": "The id of the reconciliation record." }, "from": { "type": "string", "description": "The id that was merged away." }, "into": { "type": "string", "description": "The id that kept the purchases." }, "moved": { "type": "object", "properties": { "transactions": { "type": "integer", "description": "Transactions moved. `0` when only analytics moved." }, "entitlements": { "type": "integer", "description": "Entitlements moved. `0` when only analytics moved." } } } }, "example": { "reconciliation_id": 4821, "from": "3f1c9c52-8f5e-4a52-9d9c-0f0b6f1a7c11", "into": "user_8472", "moved": { "transactions": 2, "entitlements": 1 } } } }, "responses": { "BadRequest": { "description": "Invalid request -- missing or malformed parameters.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "Unauthorized": { "description": "Invalid or missing API key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "Forbidden": { "description": "Account disabled or insufficient permissions.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "NotFound": { "description": "Resource not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "UpstreamError": { "description": "Upstream service error (Stripe, Apple).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }