openapi: 3.0.2
info:
  title: BE Merchants. Deals Products
  version: 1.0.0
  description: >-
    Billease Deals. Product Sync API


    Documentation: https://docs.billease.ph/


    Keeps your product catalog in sync with the Billease Deals catalog. This API
    is implemented on

    the Billease side and called by you.


    It is a **standalone API**, separate from the Transactions API: it belongs
    to a different

    service (`be-store-admin`, not `be-transactions`), has its own base URL, and
    is used only by

    Deals partners. Your static JWT is scoped to both.


    Sync is **push-only and partner-initiated**. There is no scheduled pull;
    catalog data moves

    only when you call `POST /products/sync`.


    1.0.0 - Initial release
  contact:
    name: IT Department
    url: https://www.billease.ph
    email: api-info@billease.ph
  x-logo:
    url: https://docs.billease.ph/images/billease-logo.png
    altText: Billease
servers:
  - url: https://store-test.billease.ph/be-store-admin-api
    description: Staging endpoint
  - url: https://store.billease.ph/be-store-admin-api
    description: Production endpoint
paths:
  /ping:
    summary: Information about service
    description: Get information about service
    get:
      operationId: ping
      summary: Information about service
      description: Check that the API is reachable and see which version is deployed.
      tags:
        - Ping
      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ping'
              examples:
                ok:
                  value:
                    nam: be-store-admin-api
                    env: prod
                    ver: 1.0.0
                    srv: '2026-08-09T13:12:01+08:00'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '500':
          description: Internal error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
  /products/sync:
    summary: Sync products
    description: Push catalog changes to the Billease Deals catalog
    post:
      operationId: syncProducts
      tags:
        - Products
      summary: Sync products
      description: >-
        The single entry point for keeping your catalog in sync with ours. Call
        it whenever your

        catalog changes. Each affected product is one entry in `products`, and
        each entry is

        resolved **independently**.


        ## How the operation is determined


        There is no `operation` field; we infer it from the fields present:


        | Operation | What you send | Applied |

        |---|---|---|

        | New product | The complete product object, with an `id` we have not
        seen before | Pending Billease approval |

        | Update | `id` + only the fields to change, and **no** `status` |
        Instantly **if** the only change is `in_stock`; otherwise pending
        approval |

        | Archive | `id` + `status: 2`, nothing else | Instantly |

        | Unarchive | `id` + `status: 1`, nothing else | Instantly |


        Only `status` values `1` and `2` may be sent; anything else is rejected
        with `400`.


        ## Approval model


        New products, and any edit other than a pure stock-count change, create
        a **pending

        version**. The currently-active version stays live until Billease
        approves the pending one.

        Stock changes, archives and unarchives bypass approval and take effect
        immediately.


        Poll `GET /products` to see what we currently hold, including anything
        still pending.


        ## Update semantics


        On an update an **absent** field means "leave unchanged"; an explicit
        **`null`** means

        "clear this field". Configure your JSON serializer to omit unset fields;
        one that emits

        `null` for unset fields will erase catalog data. To replace the image
        set, send the complete

        `image_url` array.


        ## Batch size


        At most 1000 entries per call. Larger batches are rejected with `413`.
      requestBody:
        description: Shop and merchant identifiers plus one or more product operations
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/prdSyncRequest'
            examples:
              MixedBatch:
                summary: One of each operation in a single call
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000001'
                      in_stock: 12
                    - id: '1000002'
                      name: Acme Tab 11 Wi-Fi 128GB
                    - id: '1000003'
                      status: 2
                    - id: '1000004'
                      status: 1
              NewProduct:
                summary: New product (complete object), pending approval
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000900'
                      name: Acme Phone Z 256GB Graphite
                      category: Mobile phone
                      deals_price: 29999
                      retail_price: 33999
                      in_stock: 25
                      description: Flagship smartphone with 6.7-inch OLED display.
                      device:
                        device_brand: Acme
                        device_color: Graphite
                        device_storage: 256GB
                        device_memory: 12GB
                      image_url:
                        - url: https://cdn.your-company.com/p/1000900/main.jpg
                          hash: >-
                            9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
                          order: 0
                        - url: https://cdn.your-company.com/p/1000900/back.jpg
                          hash: >-
                            2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
                          order: 1
              NewVoucher:
                summary: New voucher product, uses days_valid
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: V-500
                      name: Acme Dining Voucher PHP 500
                      category: Vouchers
                      deals_price: 450
                      retail_price: 500
                      in_stock: 1000
                      description: >-
                        PHP 500 dining credit redeemable at any Acme Bistro
                        branch.
                      days_valid: 90
                      device:
                        device_brand: Acme
                        device_merchant: Acme Bistro
                      image_url:
                        - url: https://cdn.your-company.com/v/500/card.png
                          hash: >-
                            60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752
                          order: 0
                      product_tnc: >-
                        Valid at participating branches. Not convertible to
                        cash.
              UpdateStock:
                summary: Stock count only, applied instantly
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000001'
                      in_stock: 12
              UpdatePrice:
                summary: Price change, pending approval
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000001'
                      deals_price: 27999
                      retail_price: 33999
              Archive:
                summary: Take a product down, applied instantly
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000001'
                      status: 2
              Unarchive:
                summary: Bring an archived product back, applied instantly
                value:
                  shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
                  mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
                  products:
                    - id: '1000001'
                      status: 1
      responses:
        '202':
          description: >-
            Accepted. Every entry in the batch passed validation and has been
            applied or queued for

            approval; check `applied` on each to see which. Validation failures
            never appear here:

            the whole call fails atomically with a `4xx` status (see below) and
            nothing is applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/prdSyncResponse'
              examples:
                Accepted:
                  summary: One of each operation, all accepted
                  value:
                    results:
                      - id: '1000001'
                        sku: 50231
                        operation: update
                        applied: instant
                      - id: '1000002'
                        sku: 50232
                        operation: update
                        applied: pending_approval
                      - id: '1000003'
                        sku: 50233
                        operation: archive
                        applied: instant
                      - id: '1000004'
                        sku: 50234
                        operation: unarchive
                        applied: instant
        '400':
          description: >-
            Bad Request.


            Malformed payload, or a product with an invalid or unsupported
            combination of fields:

            a `status` other than `1` or `2`, an unrecognized `category`, a
            missing required field,

            or `deals_price` above `retail_price`.

            The whole call is rejected and nothing is applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
              example:
                message: 'product 2000100501 category not found: Invalid-name-category'
        '401':
          description: Missing or invalid JWT.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '403':
          description: >-
            The JWT is valid, but `shop_code` / `mer_code` does not belong to
            you.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '404':
          description: >-
            Not Found.


            An update, archive or unarchive references an `id` we do not have on
            record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '409':
          description: >-
            Conflict.


            A new product duplicates something already in your catalog, most
            often a duplicate

            name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '413':
          description: More than 1000 entries in `products`. Split the batch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '422':
          description: |-
            Unprocessable Entity.

            A new product otherwise failed validation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '429':
          description: Too many requests. Back off and retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '500':
          description: |-
            Internal Server Error.

            Some error in the Billease system. Safe to retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
  /products:
    summary: Read products
    description: Read back what Billease currently holds for your shop
    get:
      operationId: getProducts
      tags:
        - Products
      summary: Get products
      description: >-
        Returns what we currently hold for your shop, so you can detect drift
        and see the outcome

        of anything awaiting approval.


        This is the only way to observe status `3` (Pending) and `4` (Rejected);
        the sync response

        tells you a pending version was created, not what happened to it
        afterwards.
      parameters:
        - name: shop_code
          description: Your shop UUID
          schema:
            type: string
            format: uuid
          in: query
          required: true
        - name: status
          description: Filter by status. Omit for all.
          schema:
            type: integer
            enum:
              - 1
              - 2
              - 3
              - 4
          in: query
          required: false
        - name: page
          description: Page number, 1-based
          schema:
            type: integer
            default: 1
          in: query
          required: false
        - name: per_page
          description: Items per page, max 200
          schema:
            type: integer
            default: 50
          in: query
          required: false
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/prdListResponse'
              examples:
                OK:
                  value:
                    pages:
                      page: 1
                      per_page: 50
                      total: 2
                    products:
                      - id: '1000001'
                        sku: 50231
                        name: Acme Phone X 128GB Midnight
                        category: Mobile phone
                        deals_price: 27999
                        retail_price: 33999
                        in_stock: 12
                        status: 1
                        updated: '2026-08-08T10:14:02+08:00'
                      - id: '1000002'
                        sku: 50232
                        name: Acme Tab 11 Wi-Fi 128GB
                        category: Tablet
                        deals_price: 18999
                        retail_price: 21999
                        in_stock: 40
                        status: 3
                        updated: '2026-08-09T09:02:44+08:00'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
        '500':
          description: Internal Server Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
  /categories:
    summary: Allowed categories
    description: The categories and device-spec keys we recognize
    get:
      operationId: getCategories
      tags:
        - Products
      summary: Get categories
      description: >-
        The categories we recognize and, for each, the device-spec keys allowed
        inside `device`.

        Both are **case-sensitive**. Read this at build time rather than
        hardcoding the list; it

        changes without a version bump.


        Device keys outside a category's list are not recognized and are
        dropped; the rest of the

        product is stored and the batch is not rejected. An unrecognized
        `category`, however, rejects

        the whole `POST /products/sync` call with `400`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/prdCategoryList'
              examples:
                OK:
                  value:
                    categories:
                      - category: Mobile phone
                        device_keys:
                          - device_brand
                          - device_color
                          - device_camera
                          - device_display
                          - device_processor
                          - device_memory
                          - device_storage
                          - device_os
                      - category: Vouchers
                        device_keys:
                          - device_brand
                          - device_merchant
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/message'
components:
  schemas:
    prdSyncRequest:
      title: Root Type for prdSyncRequest
      description: A batch of independent product operations for one shop
      required:
        - shop_code
        - mer_code
        - products
      type: object
      properties:
        shop_code:
          format: uuid
          description: >-
            Shop UUID code. Issued by Billease at onboarding; different per
            environment.
          type: string
        mer_code:
          format: uuid
          description: >-
            Merchant UUID code. Issued by Billease at onboarding; different per
            environment.
          type: string
        products:
          description: >-
            One or more product operations, max 1000. Each entry is resolved
            independently; the

            fields present determine the operation.
          minItems: 1
          maxItems: 1000
          type: array
          items:
            $ref: '#/components/schemas/prdProduct'
      example:
        shop_code: 3c9b1f2a-7d5e-4c11-8a6f-2b9d0e7c1a42
        mer_code: 8f1c2b7a-3e5d-4c11-9a6f-2b9d0e7c1b13
        products:
          - id: '1000001'
            in_stock: 12
    prdProduct:
      title: Root Type for prdProduct
      description: >-
        One entry in `products`. `id` is always required; everything else
        depends on the

        operation:


        - **New product**: send every field marked required below. `id` must be
        one we have not
          seen.
        - **Update**: send `id` plus only the fields to change, and no `status`.

        - **Archive / unarchive**: send `id` plus `status` and nothing else.


        Because the operation is inferred from field presence rather than
        declared, a typo in `id`

        creates a duplicate product instead of returning an error. Treat `id` as
        immutable.
      required:
        - id
      type: object
      properties:
        id:
          description: >-
            Your product id, stable and unique. We key all matching, updates,
            archives and

            unarchives on it, and map it to our internal `sku`. It must never
            change or be reused.


            This is the same value you receive as `product_id` on `POST
            /trx/transaction`.
          type: string
        name:
          nullable: true
          description: >-
            Product display name. Required for a new product. Must be unique in
            your catalog.
          type: string
        category:
          description: >-
            Category name, case-sensitive. Required for a new product. Must be
            one of the values

            from `GET /categories`; an unrecognized category rejects the whole
            call with `400`.
          type: string
        deals_price:
          nullable: true
          description: >-
            Discounted / deal price, in major currency units, no decimals.
            Required for a new

            product. Equal to `retail_price` means not discounted. Must not
            exceed `retail_price`.
          type: integer
        retail_price:
          nullable: true
          description: >-
            Suggested retail / "was" price, used for strike-through display.
            Required for a new product.
          type: integer
        in_stock:
          description: >-
            Available quantity. `0` means out of stock. Required for a new
            product.


            When this is the **only** changed field the update is applied
            instantly, with no

            approval. Everything else creates a pending version.
          type: integer
        description:
          nullable: true
          description: Product description / marketing copy. Required for a new product.
          type: string
        device:
          $ref: '#/components/schemas/prdDevice'
        image_url:
          nullable: true
          description: >-
            Product images, ordered. Required for a new product; at least one,
            at least two

            recommended. On an update, send the complete array to replace the
            set.
          type: array
          items:
            $ref: '#/components/schemas/prdImage'
        status:
          description: >-
            Send **only** to archive or unarchive, and send nothing else
            alongside it.


            - `1` - Active / approved. Send this to unarchive.

            - `2` - Archived. Send this to archive.


            `3` (Pending) and `4` (Rejected) are set by us and appear only on
            `GET /products`.
          enum:
            - 1
            - 2
          type: integer
        days_valid:
          nullable: true
          description: >-
            *Voucher products only.* Validity of the voucher denomination in
            days, minimum `1`.

            Takes precedence if `expiry_date_time` is also sent.
          type: integer
        expiry_date_time:
          format: date-time
          nullable: true
          description: >-
            *Voucher products only.* Absolute expiry. Ignored if `days_valid` is
            present.
          type: string
        product_tagline:
          nullable: true
          description: Short marketing tagline. Optional, recommended.
          type: string
        product_tnc:
          nullable: true
          description: >-
            Terms and conditions for this product. Free text, markdown.
            Optional, recommended.
          type: string
        product_link:
          nullable: true
          description: URL of the product on your site. Optional, recommended.
          type: string
      example:
        id: '1000001'
        in_stock: 12
    prdDevice:
      title: Root Type for prdDevice
      description: >-
        Device specifications: a flat map of string keys to string values. Only
        the keys allowed for

        the product's `category` are recognized; call `GET /categories` for the
        list. Keys outside

        it are not recognized and are dropped, so send only the keys that
        category allows.


        All keys are optional; send only the ones you have. Values are strings
        even when they look

        numeric.
      type: object
      additionalProperties:
        type: string
      example:
        device_brand: Acme
        device_color: Graphite
        device_storage: 256GB
        device_memory: 12GB
    prdImage:
      title: Root Type for prdImage
      description: A product image
      required:
        - url
        - hash
        - order
      type: object
      properties:
        url:
          description: >-
            Publicly reachable image URL. It must stay reachable for as long as
            the product is live.
          type: string
        hash:
          description: >-
            Lowercase hex **SHA-256** of the raw image bytes. We compare it
            against the stored value

            to decide whether to re-download.
          type: string
        order:
          description: Display order, 0-based. The image with `order` `0` is the thumbnail.
          type: integer
      example:
        url: https://cdn.your-company.com/p/1000900/main.jpg
        hash: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
        order: 0
    prdSyncResponse:
      title: Root Type for prdSyncResponse
      description: Per-product outcome for an accepted batch, in request order
      required:
        - results
      type: object
      properties:
        results:
          description: >-
            One entry per submitted product, in request order. Present only on
            `202`; a validation

            failure returns a `4xx` instead and applies nothing.
          type: array
          items:
            $ref: '#/components/schemas/prdSyncResult'
      example:
        results:
          - id: '1000001'
            sku: 50231
            operation: update
            applied: instant
    prdSyncResult:
      title: Root Type for prdSyncResult
      description: Outcome for one accepted product
      required:
        - id
        - operation
        - applied
      type: object
      properties:
        id:
          description: Your product id, echoed back
          type: string
        sku:
          description: Our internal identifier for this product.
          type: integer
        operation:
          description: The operation we inferred from the fields you sent
          enum:
            - create
            - update
            - archive
            - unarchive
          type: string
        applied:
          description: >-
            - `instant` - already live

            - `pending_approval` - a pending version was created; the active
            version stays live
              until Billease approves it. Poll `GET /products` for the outcome.
          enum:
            - instant
            - pending_approval
          type: string
      example:
        id: '1000001'
        sku: 50231
        operation: update
        applied: instant
    prdListResponse:
      title: Root Type for prdListResponse
      description: A page of products as Billease currently holds them
      required:
        - pages
        - products
      type: object
      properties:
        pages:
          $ref: '#/components/schemas/prdPages'
        products:
          type: array
          items:
            $ref: '#/components/schemas/prdProductRead'
    prdPages:
      title: Root Type for prdPages
      description: Pagination
      type: object
      properties:
        page:
          description: Current page, 1-based
          type: integer
        per_page:
          description: Items per page
          type: integer
        total:
          description: Total items matching the query
          type: integer
      example:
        page: 1
        per_page: 50
        total: 2
    prdProductRead:
      title: Root Type for prdProductRead
      description: A product as Billease currently holds it
      type: object
      properties:
        id:
          description: Your product id
          type: string
        sku:
          description: Our internal identifier
          type: integer
        name:
          type: string
        category:
          type: string
        deals_price:
          type: integer
        retail_price:
          type: integer
        in_stock:
          type: integer
        status:
          description: |-
            - `1` - Active / approved (the live version)
            - `2` - Archived
            - `3` - Pending Billease approval
            - `4` - Rejected. See `message`.
          enum:
            - 1
            - 2
            - 3
            - 4
          type: integer
        message:
          description: Reason, when `status` is `4`
          type: string
        updated:
          format: date-time
          description: When this version last changed on our side
          type: string
      example:
        id: '1000001'
        sku: 50231
        name: Acme Phone X 128GB Midnight
        category: Mobile phone
        deals_price: 27999
        retail_price: 33999
        in_stock: 12
        status: 1
        updated: '2026-08-08T10:14:02+08:00'
    prdCategoryList:
      title: Root Type for prdCategoryList
      description: Categories and their allowed device keys
      required:
        - categories
      type: object
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/prdCategory'
    prdCategory:
      title: Root Type for prdCategory
      description: One category and the device-spec keys it permits
      required:
        - category
        - device_keys
      type: object
      properties:
        category:
          description: Category name, case-sensitive
          type: string
        device_keys:
          description: Device-spec keys allowed for this category, case-sensitive
          type: array
          items:
            type: string
      example:
        category: Mobile phone
        device_keys:
          - device_brand
          - device_color
          - device_camera
          - device_display
          - device_processor
          - device_memory
          - device_storage
          - device_os
    ping:
      title: Root Type for ping
      description: Information about service
      type: object
      properties:
        nam:
          description: Service name
          type: string
        env:
          description: Environment
          type: string
        ver:
          description: Version
          type: string
        srv:
          format: date-time
          description: Server time
          type: string
      example:
        nam: be-store-admin-api
        env: prod
        ver: 1.0.0
        srv: '2026-08-09T13:12:01+08:00'
    message:
      title: Root Type for message
      description: Simple message
      type: object
      properties:
        message:
          type: string
      example:
        message: Unauthorized
    error:
      title: Root Type for error
      description: Error
      type: object
      properties:
        code:
          description: Machine-readable error code
          type: string
        message:
          description: Human-readable description
          type: string
      example:
        code: VALIDATION_ERROR
        message: products must contain at least one entry
  securitySchemes:
    Bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Your static partner JWT, generated in the

        [Merchants Portal](https://merchant.billease.ph/). The same token
        authenticates the

        Transactions API. Staging and production tokens are separate and cannot
        be interchanged.
security:
  - Bearer: []
tags:
  - name: Ping
    description: Information about service
  - name: Products
    description: Catalog synchronisation
