openapi: 3.0.3

info:
  title: ala.menu Catalog Sync API
  version: "1.0.0"
  description: >
    Keeps a menu on ala.menu in sync with a POS or back-office system.


    Your system is the source of truth for facts: price, stock, name, category.
    You push changes to POST /sync and the menu updates within seconds.
    Presentation on the menu (layout, photos, descriptions, ordering) is managed
    on the ala.menu side and is never affected by your calls.


    Nothing you send can delete anything. Items you stop sending, or mark idle,
    are hidden and can come back.


    Two tiers of failure on POST /sync:

    * Whole-call reject: HTTP 422 with `{ "ok": false, "error": "..." }`.
      Nothing is written. Fix the payload and resend.

    * Per-item reject: HTTP 200 with the rows that failed listed in `rejected[]`.
      The rest of the batch landed. One bad row never strands a full sync.


    Every request is idempotent. Retrying is always safe.

servers:
  - url: https://ala.menu/api/pos/v1

security:
  - bearerAuth: []

tags:
  - name: Sync
    description: Push catalog changes.
  - name: Verify
    description: Check the key and read back what we hold.
  - name: Images
    description: Upload item photos.

paths:
  /ping:
    get:
      tags: [Verify]
      summary: Check the key
      description: >
        Verifies your API key and returns the menu it is tied to. Run this first,
        so a key pasted into the wrong environment is caught before the first sync.
      operationId: ping
      responses:
        "200":
          description: The key is valid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PingResponse"
              example:
                ok: true
                menu: Pegasus Pet Center
                currency: USD
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/IntegrationInactive"
        "404":
          $ref: "#/components/responses/MenuNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /catalog:
    get:
      tags: [Verify]
      summary: Read what we hold
      description: >
        Returns the current state of every item on the menu that is linked to one
        of your external ids. Use it to confirm a sync landed and to see which
        external ids we already know.


        This is the menu's state, not an echo of your last payload. If the menu
        owner has pinned a name or a price by editing it by hand, the pinned value
        is what appears here, because it is what a customer sees. Items with no POS
        link are not included.


        The retail facets (brand, size, barcode) come back on the rows that carry
        them, so anything you push can be read back and matched against your own
        records. A facet the item does not have is left out of the row rather than
        sent as null.
      operationId: getCatalog
      responses:
        "200":
          description: The linked catalog.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CatalogResponse"
              example:
                ok: true
                currency: USD
                items:
                  - externalId: "9003579311776"
                    name: RC Wet Sterilised Jelly 85g
                    price: 4.5
                    inStock: true
                    hidden: false
                    hasImage: true
                    brand: Royal Canin
                    size: 85g
                    barcode: "9003579311776"
                  - externalId: "9003579311714"
                    name: RC Kitten Dry 2kg
                    price: 28
                    inStock: false
                    hidden: false
                    hasImage: false
                    brand: Royal Canin
                    size: 2kg
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/IntegrationInactive"
        "404":
          $ref: "#/components/responses/MenuNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /sync:
    post:
      tags: [Sync]
      summary: Push catalog changes
      description: >
        The main endpoint. Delta calls carry up to 500 items; a full sync carries
        the entire catalog in one call, up to 25,000 items.


        `mode: "delta"` (the default) carries only what changed. Absent fields mean
        "no change". Items you leave out are untouched.


        `mode: "full"` carries your entire active catalog in ONE call (never split
        it). Everything is reconciled: drifted values are corrected and linked items
        missing from the snapshot are hidden, never deleted. Run it on a schedule you
        choose; the gap between full syncs is the longest a missed delta can stay
        wrong. Most integrations run it daily.


        Guardrail on full snapshots: if the menu holds more than 20 visible linked
        items and the snapshot leaves out more than 10% of them, the call is rejected
        with `suspect_snapshot` and nothing changes. That is the broken-export case,
        not a mass discontinuation.


        Two failure tiers. Whole-call problems return 422 and write nothing. Per-item
        problems return 200 with the failing rows in `rejected[]` while the rest of the
        batch lands.
      operationId: sync
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SyncRequest"
            examples:
              delta:
                summary: A delta, three changes
                value:
                  mode: delta
                  currency: USD
                  items:
                    - externalId: "9003579311776"
                      price: 5
                    - externalId: "9003579311714"
                      quantity: 0
                    - externalId: "6191544800014"
                      name: New Product
                      price: 12
                      quantity: 20
                      category:
                        id: "17"
                        name: Dog Food
                      brand: Royal Canin
                      size: 2kg
              full:
                summary: A full sync
                value:
                  mode: full
                  currency: USD
                  items:
                    - externalId: "9003579311776"
                      name: RC Wet Sterilised Jelly 85g
                      price: 5
                      quantity: 42
                      category:
                        id: "17"
                        name: Dog Food
                    - externalId: "9003579311714"
                      name: RC Kitten Dry 2kg
                      price: 28
                      quantity: 0
                      category:
                        id: "17"
                        name: Dog Food
      responses:
        "200":
          description: >
            The sync ran. Check `rejected[]`: it lists rows that could not be applied
            while the rest of the batch landed. An empty array means everything applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SyncResponse"
              examples:
                clean:
                  summary: Everything applied
                  value:
                    ok: true
                    received: 3
                    updated: 2
                    created: 1
                    staged: 0
                    unchanged: 0
                    hidden: 0
                    unhidden: 0
                    rejected: []
                withRejections:
                  summary: One row could not be applied
                  value:
                    ok: true
                    received: 3
                    updated: 2
                    created: 0
                    staged: 0
                    unchanged: 0
                    hidden: 0
                    unhidden: 0
                    rejected:
                      - externalId: "6191544800014"
                        reason: missing_price
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/IntegrationInactive"
        "404":
          $ref: "#/components/responses/MenuNotFound"
        "422":
          description: >
            Whole-call reject. Nothing was written. `error` is one of:
            `invalid_body`, `invalid_mode`, `invalid_currency`, `currency_mismatch`,
            `invalid_items`, `empty_items`, `too_many_items`, `duplicate_external_id`,
            `suspect_snapshot`, `category_doc_too_large`, `duplicate_category_id`,
            `embedded_menu_doc`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                currencyMismatch:
                  value:
                    ok: false
                    error: currency_mismatch
                duplicateExternalId:
                  value:
                    ok: false
                    error: duplicate_external_id
                suspectSnapshot:
                  value:
                    ok: false
                    error: suspect_snapshot
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /items/{externalId}/image:
    post:
      tags: [Images]
      summary: Upload an item photo
      description: >
        Send the image file as the raw request body. JPEG and PNG only, up to 5 MB.
        The file type is checked from the first bytes of the file, not from the
        Content-Type header.


        The photo is resized to fit 1600x1600 and stored as a JPEG. It is stored as
        a POS fact like any other, so a photo the menu owner set by hand keeps
        winning: your file is kept but theirs continues to display. The `applied`
        field tells you which one is showing.


        The item must already exist on the menu. Create it with POST /sync first.
      operationId: uploadItemImage
      parameters:
        - name: externalId
          in: path
          required: true
          description: The item's id in your system, the same one you send to /sync.
          schema:
            type: string
            maxLength: 128
          example: "9003579311776"
      requestBody:
        required: true
        description: The raw image bytes.
        content:
          image/jpeg:
            schema:
              type: string
              format: binary
          image/png:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: The photo was stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ImageResponse"
              examples:
                displayed:
                  summary: Your photo is what customers see
                  value:
                    ok: true
                    imageUrl: https://firebasestorage.googleapis.com/v0/b/menu-maker-64321.appspot.com/o/user-uploads%2Facct%2Fpos%2F0d1e....jpg?alt=media&token=...
                    applied: true
                ownerPinned:
                  summary: Stored, but the owner's own photo keeps displaying
                  value:
                    ok: true
                    imageUrl: https://firebasestorage.googleapis.com/v0/b/menu-maker-64321.appspot.com/o/user-uploads%2Facct%2Fpos%2F0d1e....jpg?alt=media&token=...
                    applied: false
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/IntegrationInactive"
        "404":
          description: >
            No item on this menu carries that external id (`unknown_external_id`),
            or the key's menu no longer exists (`menu_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                ok: false
                error: unknown_external_id
        "422":
          description: >
            The upload was refused. `error` is one of: `invalid_external_id`,
            `empty_body`, `image_too_large`, `unsupported_image_type`,
            `unreadable_image`, `category_doc_too_large`, `duplicate_category_id`,
            `embedded_menu_doc`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                ok: false
                error: image_too_large
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        One secret key per menu, sent on every request as
        `Authorization: Bearer pos_YOURMENU_xxxx`. Keep it server side.

  responses:
    Unauthorized:
      description: >
        The key is missing, malformed, or unknown. Always `unauthorized`, the three
        cases are not distinguished to the caller.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: unauthorized
    IntegrationInactive:
      description: The key is valid but the POS integration is switched off for this menu.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: integration_inactive
    MenuNotFound:
      description: The menu this key belongs to no longer exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: menu_not_found
    RateLimited:
      description: >
        Over 120 requests per minute for this menu, counted across all four
        endpoints. Wait and retry, or batch more items per call.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: rate_limited
    InternalError:
      description: Something failed on our side. Nothing partial is written. Retry.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            ok: false
            error: internal_error

  schemas:
    Error:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          enum: [false]
        error:
          type: string
          description: A stable machine-readable code. Safe to log and branch on.
          example: currency_mismatch

    PingResponse:
      type: object
      required: [ok, menu, currency]
      properties:
        ok:
          type: boolean
          enum: [true]
        menu:
          type: string
          nullable: true
          description: The menu's display name, or null if it has none.
        currency:
          type: string
          description: The ISO code every payload must match.
          example: USD

    CatalogItem:
      type: object
      required: [externalId, name, price, inStock, hidden, hasImage]
      properties:
        externalId:
          type: string
          description: Your own id for the item.
        name:
          type: string
          nullable: true
          description: The name customers see, which may be an owner edit rather than yours.
        price:
          type: number
          nullable: true
          description: The price customers see, which may be an owner edit rather than yours.
        inStock:
          type: boolean
          description: False when the item is sold out on the menu.
        hidden:
          type: boolean
          description: >
            True when the item is off the menu: marked idle, held for review, or
            missing from the last full snapshot. It still exists and can come back.
        hasImage:
          type: boolean
          description: True when the item has a photo, from any source.
        brand:
          type: string
          description: >
            The brand on the menu, which may be an owner edit rather than yours.
            Absent from the row when the item has none.
        size:
          type: string
          description: >
            The pack size on the menu. Absent from the row when the item has none.
        barcode:
          type: string
          description: >
            The barcode on the menu. Absent from the row when the item has none.
            Useful as a join key when your externalId is not the barcode.

    CatalogResponse:
      type: object
      required: [ok, currency, items]
      properties:
        ok:
          type: boolean
          example: true
        currency:
          type: string
          example: USD
        items:
          type: array
          items:
            $ref: "#/components/schemas/CatalogItem"

    SyncCategory:
      type: object
      required: [id]
      description: >
        The category in your system. Unknown ids create a new menu section
        automatically. Send null to clear the category and drop the item into the
        fallback section.
      properties:
        id:
          type: string
          maxLength: 128
          description: Your own stable category id.
        name:
          type: string
          maxLength: 200
          description: The category name. Used when a new section has to be created.

    SyncItem:
      type: object
      required: [externalId]
      description: >
        One item. Only `externalId` is required. Absent fields mean "no change".
        `name` and `price` can never be null. An unknown `externalId` creates the
        item, and creating needs both `name` and `price`.
      properties:
        externalId:
          type: string
          maxLength: 128
          description: >
            Any stable string identifying the item in your system. A barcode is
            ideal. It must never change. It cannot contain a slash or control
            characters, cannot start with a double underscore, and must contain at
            least one letter or digit.
          example: "9003579311776"
        name:
          type: string
          minLength: 1
          maxLength: 200
        price:
          type: number
          minimum: 0
          description: In the menu's currency. Never null.
        quantity:
          type: number
          minimum: 0
          description: Real stock count. 0 marks the item sold out, above 0 brings it back.
        status:
          type: string
          enum: [active, idle]
          description: >
            `idle` hides the item from the menu without deleting it. `active` brings
            a hidden item back. Absent means no change.
        category:
          allOf:
            - $ref: "#/components/schemas/SyncCategory"
          nullable: true
        imageUrl:
          type: string
          format: uri
          nullable: true
          maxLength: 2000
          description: >
            An http or https URL to the item's photo, if you host images yourself.
            Send null to clear the photo we hold. To upload a file instead, use
            POST /items/{externalId}/image.
        brand:
          type: string
          minLength: 1
          maxLength: 200
          nullable: true
          description: >
            The product brand, shown on the product itself. Trimmed, and never
            empty once trimmed. Send null to clear it.
        size:
          type: string
          minLength: 1
          maxLength: 200
          nullable: true
          description: >
            The pack size as the label reads it, such as "500g" or "6 x 1L".
            Trimmed, and never empty once trimmed. Send null to clear it.
          example: 500g
        barcode:
          type: string
          minLength: 1
          maxLength: 64
          nullable: true
          description: >
            The item's barcode, worth sending when it is not already the
            externalId. Always a string, even when it is all digits. Trimmed, and
            never empty once trimmed. Send null to clear it.
          example: "5901234123457"

    SyncRequest:
      type: object
      required: [currency, items]
      properties:
        mode:
          type: string
          enum: [delta, full]
          default: delta
          description: >
            `delta` carries only what changed. `full` carries your entire active
            catalog and reconciles everything.
        currency:
          type: string
          pattern: "^[A-Za-z]{3}$"
          description: >
            ISO code. Required on every call, and it must match the menu's currency
            or the whole call is rejected with `currency_mismatch`.
          example: USD
        items:
          type: array
          minItems: 1
          # 500 applies to delta calls; mode "full" accepts up to 25000 so the
          # entire catalog always fits in one call.
          maxItems: 25000
          items:
            $ref: "#/components/schemas/SyncItem"

    Rejection:
      type: object
      required: [externalId, reason]
      properties:
        externalId:
          type: string
          description: The row's external id, or an empty string if it had no usable one.
        reason:
          type: string
          enum:
            - invalid_item
            - invalid_external_id
            - invalid_null
            - invalid_name
            - invalid_price
            - invalid_quantity
            - invalid_status
            - invalid_category
            - invalid_image
            - invalid_brand
            - invalid_size
            - invalid_barcode
            - missing_name
            - missing_price
            - item_not_found
          description: >
            Why this one row was skipped. `missing_name` and `missing_price` mean a
            new item arrived without the two fields a creation needs. `invalid_null`
            means `name` or `price` was sent as null, which is never an instruction.

    SyncResponse:
      type: object
      required:
        [ok, received, updated, created, staged, unchanged, hidden, unhidden, rejected]
      properties:
        ok:
          type: boolean
          enum: [true]
        received:
          type: integer
          description: How many rows were in your `items` array, including rejected ones.
        updated:
          type: integer
          description: Existing items where something actually changed.
        created:
          type: integer
          description: New items created and published on the menu.
        staged:
          type: integer
          description: >
            New items created but deliberately held off the menu: the owner asked for
            a review step for new items, the item arrived with no photo and the owner
            asked to hold those, or you sent it with `status: "idle"`. They exist and
            wait to be released.
        unchanged:
          type: integer
          description: Existing items whose payload matched what we already held.
        hidden:
          type: integer
          description: >
            Items that went from visible to hidden on this call: marked idle, or
            absent from a full snapshot.
        unhidden:
          type: integer
          description: >
            Items that came back from hidden on this call: an explicit
            `status: "active"`, or an item that a previous snapshot had missed
            reappearing in this one.
        rejected:
          type: array
          description: Rows that could not be applied. The rest of the batch still landed.
          items:
            $ref: "#/components/schemas/Rejection"

    ImageResponse:
      type: object
      required: [ok, imageUrl, applied]
      properties:
        ok:
          type: boolean
          enum: [true]
        imageUrl:
          type: string
          format: uri
          description: Where your photo is stored. Immutable, every upload gets its own URL.
        applied:
          type: boolean
          description: >
            True when your photo is the one customers now see. False when the menu
            owner has pinned their own photo for this item; yours is stored but theirs
            keeps displaying.
