openapi: 3.1.0
info:
  title: Engage Mobile Edge API
  version: 1.0.0
  description: |
    Normative wire contract used by Engage mobile SDKs. The application-facing SDK API is
    documented in engage-saas/sdk.md. An app key is a public bootstrap identifier. Every request
    after bootstrap is authorized by an opaque credential scoped to one installation.
servers:
  - url: /v1
tags:
  - name: Bootstrap
  - name: Installation
  - name: Identity
  - name: Operations
  - name: Synchronization
  - name: Inbox
  - name: Privacy
paths:
  /sdk/installations:
    post:
      tags: [Bootstrap]
      operationId: bootstrapInstallation
      summary: Create or recover an installation and mint its credential
      security: []
      parameters:
        - $ref: '#/components/parameters/AppKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BootstrapInstallationRequest'
      responses:
        '201':
          description: Installation created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstallationSession'
        '200':
          description: Existing installation credential recovered through a valid recovery token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstallationSession'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/installation:
    get:
      tags: [Installation]
      operationId: getInstallation
      security: [{ installationBearer: [] }]
      responses:
        '200':
          description: Current server state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstallationState'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/installation/binding-code:
    post:
      tags: [Identity]
      operationId: issueBindingCode
      description: |
        Returns the single pending code for the current installation, or atomically reserves the
        next generation and creates one. Issuing the code does not change the active binding.
      security: [{ installationBearer: [] }]
      responses:
        '200':
          description: Existing pending code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BindingCode' }
        '201':
          description: New pending code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BindingCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /installation-binding-transitions:
    post:
      tags: [Identity]
      operationId: transitionInstallationBinding
      description: Server-to-server operation. The target is derived by the App backend.
      security: [{ serviceBearer: [] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BindingTransitionRequest' }
      responses:
        '200':
          description: Existing idempotent result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BindingTransition' }
        '201':
          description: Transition committed atomically
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BindingTransition' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Code expired, consumed, or idempotency key reused with another request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Problem' }
  /sdk/operations:batch:
    post:
      tags: [Operations]
      operationId: ingestInstallationOperations
      description: |
        Applies durable SDK outbox operations. Each operation ID is idempotent for the installation.
        Operations are evaluated against the generation in which they were created and are never
        reassigned to a later profile binding.
      security: [{ installationBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OperationBatchRequest' }
      responses:
        '200':
          description: Batch previously completed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OperationBatchResult' }
        '202':
          description: Batch processed; individual operations may be permanently rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OperationBatchResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/sync:
    post:
      tags: [Synchronization]
      operationId: synchronizeInstallation
      description: Returns an atomic snapshot or delta for the requested installed modules.
      security: [{ installationBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SyncRequest' }
      responses:
        '200':
          description: Snapshot or delta belonging to the current binding generation
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/inbox:
    get:
      tags: [Inbox]
      operationId: listInboxEntries
      security: [{ installationBearer: [] }]
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
        - name: pageSize
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: sortOrder
          in: query
          description: Stable ordering applied to the complete cursor-paginated inbox.
          schema: { $ref: '#/components/schemas/InboxSortOrder' }
      responses:
        '200':
          description: Stable cursor page resolved for installation and current profile scope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InboxPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/inbox/operations:batch:
    post:
      tags: [Inbox]
      operationId: mutateInboxEntries
      security: [{ installationBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InboxOperationBatch' }
      responses:
        '202':
          description: Idempotent mutations accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OperationBatchResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/inbox/renderings:resolve:
    post:
      tags: [Inbox]
      operationId: resolveInboxRenderings
      description: |
        Resolves optional immutable rendering snapshots for effective Inbox entries, including an
        entry opened directly from a push or deep link before it appears in a local page. The server
        authorizes every requested entry against the current installation and omits inaccessible,
        deleted, expired, or unrenderable entries. Headless Inbox pages never include these documents.
      security: [{ installationBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InboxRenderingRequest' }
      responses:
        '200':
          description: Available rendering snapshots; entries without the requested representation are omitted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InboxRenderingBatch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /sdk/privacy/revocations/{operationId}:
    put:
      tags: [Privacy]
      operationId: revokeInstallation
      description: |
        Idempotently revokes and erases one installation. This route accepts only the limited
        revocation credential retained by optOutAndWipe, never an ordinary installation credential.
      security: [{ revocationBearer: [] }]
      parameters:
        - name: operationId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Revocation completed or already completed }
        '401': { $ref: '#/components/responses/Unauthorized' }
components:
  securitySchemes:
    installationBearer:
      type: http
      scheme: bearer
      bearerFormat: opaque-installation-credential
    serviceBearer:
      type: http
      scheme: bearer
      bearerFormat: engage-service-credential
    revocationBearer:
      type: http
      scheme: bearer
      bearerFormat: opaque-revocation-credential
  parameters:
    AppKey:
      name: X-Engage-App-Key
      in: header
      required: true
      description: Public identifier of one enabled platform app.
      schema: { type: string, pattern: '^eng_app_' }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Invalid, expired, revoked, or incorrectly scoped credential
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
  schemas:
    Problem:
      type: object
      required: [code, message]
      properties:
        code: { type: string }
        message: { type: string }
    Platform:
      type: string
      enum: [ANDROID, IOS]
    BootstrapInstallationRequest:
      type: object
      additionalProperties: false
      required: [platform, locale, timezone, sdkVersion, appVersion]
      properties:
        platform: { $ref: '#/components/schemas/Platform' }
        locale: { type: string, maxLength: 35 }
        timezone: { type: string, maxLength: 64 }
        sdkVersion: { type: string, maxLength: 64 }
        appVersion: { type: string, maxLength: 64 }
        appBuild: { type: string, maxLength: 64 }
        deviceModel: { type: string, maxLength: 128 }
        osVersion: { type: string, maxLength: 64 }
        recoveryToken:
          type: string
          description: Optional locally persisted token used only to recover the same installation.
    InstallationSession:
      type: object
      required:
        - installationId
        - credential
        - revocationCredential
        - recoveryToken
        - generation
        - privacy
        - pushSubscription
        - pushPermission
        - serverTime
      properties:
        installationId: { type: string, format: uuid }
        credential: { type: string }
        revocationCredential: { type: string }
        recoveryToken: { type: string }
        generation: { type: integer, format: int64, minimum: 0 }
        privacy: { $ref: '#/components/schemas/PrivacyState' }
        pushSubscription: { $ref: '#/components/schemas/PushSubscriptionState' }
        pushPermission: { $ref: '#/components/schemas/PushPermissionState' }
        serverTime: { type: string, format: date-time }
    InstallationState:
      type: object
      additionalProperties: false
      required:
        - installationId
        - generation
        - bindingState
        - privacy
        - pushSubscription
        - pushPermission
        - bound
        - updatedAt
      properties:
        installationId: { type: string, format: uuid }
        generation: { type: integer, format: int64, minimum: 0 }
        bindingState: { type: string, enum: [ANONYMOUS, BOUND] }
        privacy: { $ref: '#/components/schemas/PrivacyState' }
        pushSubscription: { $ref: '#/components/schemas/PushSubscriptionState' }
        pushPermission: { $ref: '#/components/schemas/PushPermissionState' }
        bound: { type: boolean }
        updatedAt: { type: string, format: date-time }
    PrivacyState:
      type: string
      enum: [OPTED_IN, OPTED_OUT]
    PushSubscriptionState:
      type: string
      enum: [OPTED_IN, OPTED_OUT]
    PushPermissionState:
      type: string
      enum: [NOT_DETERMINED, DENIED, AUTHORIZED, PROVISIONAL, EPHEMERAL]
    BindingCode:
      type: object
      required: [code, expiresAt]
      properties:
        code: { type: string }
        expiresAt: { type: string, format: date-time }
    BindingTarget:
      oneOf:
        - type: object
          additionalProperties: false
          required: [externalId]
          properties:
            externalId: { type: string, minLength: 1, maxLength: 255 }
        - type: 'null'
    BindingTransitionRequest:
      type: object
      additionalProperties: false
      required: [bindingCode, target]
      properties:
        bindingCode: { type: string }
        target: { $ref: '#/components/schemas/BindingTarget' }
    BindingTransition:
      type: object
      required: [transitionId, installationId, generation, state, committedAt]
      properties:
        transitionId: { type: string, format: uuid }
        installationId: { type: string, format: uuid }
        generation: { type: integer, format: int64, minimum: 1 }
        state: { type: string, enum: [BOUND, ANONYMOUS] }
        committedAt: { type: string, format: date-time }
    OperationBatchRequest:
      type: object
      additionalProperties: false
      required: [batchId, operations]
      properties:
        batchId: { type: string, format: uuid }
        operations:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/SdkOperation' }
    SdkOperation:
      type: object
      additionalProperties: false
      required: [operationId, generation, type, occurredAt, payload]
      properties:
        operationId: { type: string, format: uuid }
        generation: { type: integer, format: int64, minimum: 0 }
        type:
          type: string
          enum:
            - EVENT_TRACKED
            - SCREEN_VIEWED
            - SCREEN_CLEARED
            - INSTALLATION_ATTRIBUTES_EDITED
            - PROFILE_ATTRIBUTES_EDITED
            - PROFILE_TAGS_EDITED
            - INSTALLATION_SUBSCRIPTIONS_EDITED
            - PROFILE_SUBSCRIPTIONS_EDITED
            - PUSH_TOKEN_SET
            - PUSH_SUBSCRIPTION_SET
            - PUSH_PERMISSION_SET
            - PRIVACY_STATE_SET
            - INTERACTION_TRACKED
            - PUSH_RECEIPT_RECORDED
            - FLAG_EXPOSED
        occurredAt: { type: string, format: date-time }
        payload: { type: object }
      allOf:
        - if: { properties: { type: { const: EVENT_TRACKED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/EventTrackedPayload' } } }
        - if: { properties: { type: { const: SCREEN_VIEWED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/ScreenViewedPayload' } } }
        - if: { properties: { type: { const: SCREEN_CLEARED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/ScreenClearedPayload' } } }
        - if: { properties: { type: { const: INSTALLATION_ATTRIBUTES_EDITED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/AttributeEditPayload' } } }
        - if: { properties: { type: { const: PROFILE_ATTRIBUTES_EDITED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/AttributeEditPayload' } } }
        - if: { properties: { type: { const: PROFILE_TAGS_EDITED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/TagEditPayload' } } }
        - if: { properties: { type: { const: INSTALLATION_SUBSCRIPTIONS_EDITED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/InstallationSubscriptionEditPayload' } } }
        - if: { properties: { type: { const: PROFILE_SUBSCRIPTIONS_EDITED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/ProfileSubscriptionEditPayload' } } }
        - if: { properties: { type: { const: PUSH_TOKEN_SET } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/PushTokenPayload' } } }
        - if: { properties: { type: { const: PUSH_SUBSCRIPTION_SET } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/PushSubscriptionPayload' } } }
        - if: { properties: { type: { const: PUSH_PERMISSION_SET } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/PushPermissionPayload' } } }
        - if: { properties: { type: { const: PRIVACY_STATE_SET } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/PrivacyStatePayload' } } }
        - if: { properties: { type: { const: INTERACTION_TRACKED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/InteractionTrackedPayload' } } }
        - if: { properties: { type: { const: PUSH_RECEIPT_RECORDED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/PushReceiptPayload' } } }
        - if: { properties: { type: { const: FLAG_EXPOSED } }, required: [type] }
          then: { properties: { payload: { $ref: '#/components/schemas/FlagExposurePayload' } } }
    EventTrackedPayload:
      type: object
      additionalProperties: false
      required: [name, properties]
      properties:
        name: { type: string, pattern: '^[a-z][a-z0-9_]{1,63}$' }
        properties: { type: object, additionalProperties: true }
        value: { type: [number, 'null'] }
        transactionId: { type: [string, 'null'], maxLength: 255 }
    ScreenViewedPayload:
      type: object
      additionalProperties: false
      required: [screen_key]
      properties:
        screen_key: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
        previous_screen_key: { type: [string, 'null'], pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
        previous_visible_duration_millis: { type: [integer, 'null'], format: int64, minimum: 0 }
    ScreenClearedPayload:
      type: object
      additionalProperties: false
      required: [screen_key, visible_duration_millis]
      properties:
        screen_key: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
        visible_duration_millis: { type: integer, format: int64, minimum: 0 }
    AttributeEditPayload:
      type: object
      additionalProperties: false
      properties:
        set:
          type: object
          maxProperties: 200
          propertyNames: { pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
          additionalProperties: true
        remove:
          type: array
          uniqueItems: true
          items: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
    TagEditPayload:
      type: object
      additionalProperties: false
      properties:
        add:
          type: array
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 64 }
        remove:
          type: array
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 64 }
    InstallationSubscriptionEditPayload:
      type: object
      additionalProperties: false
      required: [changes]
      properties:
        changes:
          type: array
          minItems: 1
          items:
            type: object
            additionalProperties: false
            required: [list, subscribed]
            properties:
              list: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
              subscribed: { type: boolean }
    ProfileSubscriptionEditPayload:
      type: object
      additionalProperties: false
      required: [changes]
      properties:
        changes:
          type: array
          minItems: 1
          items:
            type: object
            additionalProperties: false
            required: [list, channel, subscribed]
            properties:
              list: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
              channel: { type: string, enum: [PUSH, EMAIL, SMS, WHATSAPP] }
              subscribed: { type: boolean }
    PushTokenPayload:
      type: object
      additionalProperties: false
      required: [token]
      properties:
        token: { type: [string, 'null'], maxLength: 4096 }
    PushSubscriptionPayload:
      type: object
      additionalProperties: false
      required: [state]
      properties:
        state: { type: string, enum: [OPTED_IN, OPTED_OUT] }
    PushPermissionPayload:
      type: object
      additionalProperties: false
      required: [state]
      properties:
        state: { $ref: '#/components/schemas/PushPermissionState' }
    PrivacyStatePayload:
      type: object
      additionalProperties: false
      required: [state]
      properties:
        state: { type: string, enum: [OPTED_IN, OPTED_OUT] }
    InteractionTrackedPayload:
      type: object
      additionalProperties: false
      required: [experienceId, messageId, type]
      properties:
        experienceId: { type: string, format: uuid }
        messageId: { type: string, minLength: 1, maxLength: 255 }
        variantId: { type: [string, 'null'], maxLength: 255 }
        type: { type: string, enum: [IMPRESSION, CLICK, DISMISS, CONVERSION] }
    PushReceiptPayload:
      type: object
      additionalProperties: false
      required: [deliveryId, type]
      properties:
        deliveryId: { type: string, format: uuid }
        type: { type: string, enum: [DELIVERED, OPENED] }
    FlagExposurePayload:
      type: object
      additionalProperties: false
      required: [flagKey, experimentId, variantKey, revision]
      properties:
        flagKey: { type: string, pattern: '^[a-z][a-z0-9_.-]{0,127}$' }
        experimentId: { type: string, minLength: 1, maxLength: 255 }
        variantKey: { type: string, minLength: 1, maxLength: 255 }
        revision: { type: integer, format: int64, minimum: 1 }
    OperationStatus:
      type: string
      enum: [ACCEPTED, DUPLICATE, REJECTED]
    OperationResult:
      type: object
      required: [operationId, status]
      properties:
        operationId: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OperationStatus' }
        errorCode: { type: [string, 'null'] }
        message: { type: [string, 'null'] }
    OperationBatchResult:
      type: object
      required: [batchId, results, serverTime]
      properties:
        batchId: { type: string, format: uuid }
        results:
          type: array
          items: { $ref: '#/components/schemas/OperationResult' }
        serverTime: { type: string, format: date-time }
    SdkModule:
      type: string
      enum: [PUSH, IN_APP, PREFERENCES, FEATURE_FLAGS]
    SyncRequest:
      type: object
      additionalProperties: false
      required: [modules]
      properties:
        cursor: { type: [string, 'null'] }
        modules:
          type: array
          uniqueItems: true
          items: { $ref: '#/components/schemas/SdkModule' }
    SyncResponse:
      type: object
      additionalProperties: false
      required: [cursor, generation, revision, fullSnapshot, documents, tombstones, serverTime, refreshAfterSeconds]
      properties:
        cursor: { type: string }
        generation: { type: integer, format: int64, minimum: 0 }
        revision: { type: integer, format: int64, minimum: 0 }
        fullSnapshot: { type: boolean }
        documents:
          type: array
          items:
            type: object
            required: [module, key, revision, payload]
            properties:
              module: { $ref: '#/components/schemas/SdkModule' }
              key: { type: string }
              revision: { type: integer, format: int64 }
              payload: { type: object, additionalProperties: true }
        tombstones:
          type: array
          items:
            type: object
            required: [module, key, revision]
            properties:
              module: { $ref: '#/components/schemas/SdkModule' }
              key: { type: string }
              revision: { type: integer, format: int64 }
        serverTime: { type: string, format: date-time }
        refreshAfterSeconds: { type: integer, minimum: 30, default: 900 }
    InboxScope:
      type: string
      enum: [INSTALLATION, PROFILE]
    InboxSortOrder:
      type: string
      enum: [NEWEST_FIRST, OLDEST_FIRST]
      default: NEWEST_FIRST
    InboxEntry:
      type: object
      required: [id, key, payload, scope, sentAt]
      properties:
        id: { type: string, format: uuid }
        key: { type: string }
        payload: { type: object, additionalProperties: true }
        scope: { $ref: '#/components/schemas/InboxScope' }
        sentAt: { type: string, format: date-time }
        expiresAt: { type: [string, 'null'], format: date-time }
        readAt: { type: [string, 'null'], format: date-time }
    InboxPage:
      type: object
      required: [entries, nextCursor, hasMore, unreadCount]
      properties:
        entries:
          type: array
          items: { $ref: '#/components/schemas/InboxEntry' }
        nextCursor: { type: [string, 'null'] }
        hasMore: { type: boolean }
        unreadCount: { type: integer, minimum: 0 }
    InboxOperationBatch:
      type: object
      additionalProperties: false
      required: [batchId, generation, operations]
      properties:
        batchId: { type: string, format: uuid }
        generation: { type: integer, format: int64, minimum: 0 }
        operations:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [operationId, type]
            properties:
              operationId: { type: string, format: uuid }
              type: { type: string, enum: [MARK_READ, MARK_UNREAD, DELETE, MARK_ALL_READ] }
              entryId: { type: [string, 'null'], format: uuid }
    InboxRenderingRequest:
      type: object
      additionalProperties: false
      required: [entryIds]
      properties:
        entryIds:
          type: array
          minItems: 1
          maxItems: 100
          uniqueItems: true
          items: { type: string, format: uuid }
    InboxRendering:
      type: object
      additionalProperties: false
      required: [entryId, renderer, revision, surfaces, expiresAt]
      properties:
        entryId: { type: string, format: uuid }
        renderer: { type: string, enum: [DIVKIT] }
        revision: { type: integer, format: int64, minimum: 1 }
        surfaces:
          type: object
          additionalProperties: false
          required: [SUMMARY, DETAIL]
          properties:
            SUMMARY: { type: object, additionalProperties: true }
            DETAIL: { type: object, additionalProperties: true }
        expiresAt: { type: [string, 'null'], format: date-time }
    InboxRenderingBatch:
      type: object
      additionalProperties: false
      required: [renderings]
      properties:
        renderings:
          type: array
          items: { $ref: '#/components/schemas/InboxRendering' }
