openapi: 3.1.0
info:
  title: bananabread AI Product API
  version: 1.0.0
  summary: What consumers love about a product, what they wish for, and what they dislike.
  description: |
    Fields whose value is unknown are omitted from responses rather than returned as null.

    ## Authentication
    Every request carries the partner's key in the `X-API-Key` header. Keys are static, issued per
    partner, and rotated on request. You can also [create your own key](keys.html) by signing in
    with your email; self-serve keys allow 60 reads per minute and 500 product submissions per day.
    Eight real reports, exactly as the API returns them, are on the [examples page](examples.html).

    ## Pricing
    With a self-serve key, submitting a product costs $0.99, taken from prepaid credit when the
    submission is accepted. Reading products and consumer sentiment is free. A new account starts
    with $1 of welcome credit, enough for one submission. Add credit on the
    [keys page](keys.html), from $1; it never expires. A submission without enough credit is
    answered with 402 and nothing is stored. The $0.99 is given back when the submission is
    rejected, when it is an identical resubmission that changes nothing, and when consumer
    sentiment for that product cannot be collected. Keys issued to partners by bananabread are
    billed by agreement and are not charged per request.

    ## Paying with x402
    Without an API key, a submission can be paid per request with [x402](https://x402.org): $0.99 in
    USDC on Base or Polygon. `POST /products` without `X-API-Key` answers 402 with a `PAYMENT-REQUIRED` header;
    send the same request again with a signed payment in the `PAYMENT-SIGNATURE` header. The payment
    is settled only when the submission is accepted and changes something, so a rejected submission,
    or an identical resubmission of a product you already paid for, costs nothing. The answer carries
    a `read_token` that reads that product's consumer sentiment with `Authorization: Bearer`. When
    consumer sentiment cannot be collected, the USDC is sent back to the paying wallet.

    ## Identifiers
    A product's `id` is its barcode when it has one, stored as submitted (digits only, 8, 12, 13 or
    14 digits, GS1 check digit validated). Products without a barcode, and products submitted with a
    `variant`, receive a synthetic id of the form `bb` followed by 12 lowercase base36 characters
    (0-9, a-z), for example `bb7k3q9m2xf4d1`. A `variant` distinguishes several products that share
    one barcode, for example the same item bought from different suppliers. Within a partner's
    catalogue, `barcode` plus `variant` is unique. `GET /products` lists the
    products you have submitted, each with its `id`.

    ## Versioning and updates
    Each product carries a `version`, returned on submission. It increments every time the product
    changes, either because the partner resubmitted it with different details or because bananabread updated
    its methodology. Consumer sentiment belongs to a version: after a new version,
    `GET /products/{id}/consumer-sentiment` responds 202 until it has been collected again.

    ## Language
    Human-readable text is a `Text` object keyed by ISO 639-1 language code, for example `en`
    and `nl`. At least one key is present.
servers:
- url: https://product-api.bananabread.ai/v1
  description: Launch
security:
- ApiKeyAuth: []
tags:
- name: Ingest
  description: Submit or update a product.
- name: Products
  description: Look up the products you have submitted.
- name: Consumer sentiment
  description: Reviews and posts about a product, collected from online platforms and grouped by attribute.
    Each attribute is bucketed as `love`, `desire`, or `complaint`, and carries a short summary plus the
    verbatim quotes it was drawn from.
paths:
  /products:
    get:
      tags:
      - Products
      operationId: listProducts
      summary: List or search products
      description: The products you have submitted, each with its `id` and whether its consumer sentiment
        has been collected. With no filters, returns all of them, paged with `offset` and `limit`.
      parameters:
      - name: query
        in: query
        description: Free-text search over product name, brand, id, and barcode.
        schema:
          type: string
      - name: barcode
        in: query
        description: Return only products with this barcode, every `variant` included.
        schema:
          type: string
      - name: status
        in: query
        description: Filter by processing status. `status[sentiment]=done` returns only products whose
          consumer sentiment has been collected.
        style: deepObject
        explode: true
        schema:
          type: object
          properties:
            sentiment:
              $ref: '#/components/schemas/StageStatus'
        example:
          sentiment: done
      - $ref: '#/components/parameters/Offset'
      - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of products.
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - total
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ProductSummary'
                  total:
                    type: integer
                    description: Total number of products matching the filters, regardless of `offset`
                      and `limit`.
              examples:
                catalogue:
                  $ref: '#/components/examples/ShinRamyunList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
      - Ingest
      operationId: createProduct
      summary: Submit a product
      description: |
        Accepts a product and returns immediately with its `id`. Use that `id` to read
        `GET /products/{id}/consumer-sentiment`; consumer sentiment starts when first read.

        Resubmitting the same `barcode` and `variant` updates the product. The product keeps its
        `id`; `version` increments when anything changed. A product submitted without a `barcode`
        is always stored as a new product.

        With a self-serve key each accepted submission costs $0.99 of prepaid credit, see Pricing.
        An identical resubmission is free.

        Without an API key, pay with x402 instead, see Paying with x402. The 202 then also carries a
        `read_token` and a `PAYMENT-RESPONSE` header with the settlement.

        To retry safely, send an `Idempotency-Key` header. The first accepted submission with a key is
        kept for your account: the same key with the same body returns the same `id` and `version`
        again without a new charge, marked by the response header `Idempotent-Replayed: true`. The same
        key with a different body is answered with 409.
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Optional. Up to 255 printable characters identifying this submission, for example
          a hash of its fields.
        schema:
          type: string
          minLength: 1
          maxLength: 255
      - name: PAYMENT-SIGNATURE
        in: header
        required: false
        description: Only without `X-API-Key`. The base64-encoded x402 payment for what `PAYMENT-REQUIRED`
          asked.
        schema:
          type: string
      security:
      - ApiKeyAuth: []
      - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductMetadataInput'
            examples:
              shin_ramyun:
                $ref: '#/components/examples/ShinRamyunInput'
      responses:
        '202':
          description: Accepted for processing.
          headers:
            PAYMENT-RESPONSE:
              description: Only for a submission paid with x402. The base64-encoded settlement result,
                with the transaction hash.
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - version
                properties:
                  id:
                    $ref: '#/components/schemas/Id'
                  version:
                    type: integer
                  read_token:
                    type: string
                    description: 'Only for a submission paid with x402. Reads this product''s consumer
                      sentiment as `Authorization: Bearer <read_token>`.'
              example:
                id: '8801043015509'
                version: 1
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '409':
          description: The `Idempotency-Key` was already used with a different body, or a request with
            it is still being processed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /products/{id}/consumer-sentiment:
    parameters:
    - $ref: '#/components/parameters/ProductId'
    get:
      tags:
      - Consumer sentiment
      operationId: getConsumerSentiment
      security:
      - ApiKeyAuth: []
      - ReadToken: []
      summary: What consumers love about a product, what they wish for, and what they dislike
      description: |
        What consumers praise about this product online, what they wish it had, and what they dislike,
        grouped into attributes with supporting quotes. The first read starts collection. Until it has finished, responds 202 with
        `{ "status": "processing" }`; once finished, an empty list means nothing was found for the
        product.
      responses:
        '200':
          description: Sentiment attributes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ConsumerAttribute'
              example:
              - label:
                  en: Rich, savoury heat
                  nl: Rijke, hartige pit
                bucket: love
                summary:
                  en: The most praised point is that the broth is not only hot but savoury. Reviewers
                    say this flavour is why they keep coming back.
                  nl: Het meest geprezen punt is dat de bouillon niet alleen pittig maar ook hartig is.
                    Reviewers zeggen dat ze om deze smaak steeds terugkomen.
                quotes:
                - text: This is my favorite instant ramen. It's not just spicy -- it has a rich, savory
                    flavor that keeps me coming back.
                  kind: amazon
                  url: https://www.amazon.com/gp/customer-reviews/R3EVROMRI5K1PS
                  posted_at: '2026-07-27T00:00:00Z'
                  engagement:
                    likes: 1
                - text: It’s definitely spicy, but the flavor is what keeps me coming back. The broth
                    has a rich, savory taste that feels different from a lot of other instant noodles
                    I’ve tried.
                  kind: reddit
                  url: https://www.reddit.com/r/InstantRamen/comments/1tub9ed/tried_shin_ramyun_for_the_first_time_in_a_while/
                  posted_at: '2026-06-02T00:00:00Z'
                  engagement:
                    score: 158
        '202':
          $ref: '#/components/responses/StageProcessing'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Static key issued per partner. Rotated on request.
    ReadToken:
      type: http
      scheme: bearer
      description: The `read_token` of a submission paid with x402. Reads only that product's consumer
        sentiment.
  parameters:
    ProductId:
      name: id
      in: path
      required: true
      description: Barcode or synthetic `bb` id.
      schema:
        $ref: '#/components/schemas/Id'
    Offset:
      name: offset
      in: query
      description: Number of products to skip. Use with `limit` to page through results.
      schema:
        type: integer
        minimum: 0
        default: 0
    Limit:
      name: limit
      in: query
      description: Maximum number of products per page.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
  responses:
    RateLimited:
      description: Rate limit exceeded. Self-serve keys allow 60 reads per minute and 500 product submissions
        per day.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PaymentRequired:
      description: With an API key, not enough prepaid credit for this submission; nothing was stored,
        add credit on the keys page and submit again. Without an API key, the submission needs an x402
        payment or the payment was refused; the `PAYMENT-REQUIRED` header says what to pay.
      headers:
        PAYMENT-REQUIRED:
          description: Only without an API key. The base64-encoded x402 payment requirements, $0.99 in
            USDC on Base or Polygon.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: payment_required
            message: 'not enough credit: submit_product costs $0.99'
    BadRequest:
      description: Validation error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: No product matches.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    StageProcessing:
      description: The stage has not finished. Read again until it responds 200.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StageProcessing'
          example:
            status: processing
  schemas:
    Id:
      type: string
      description: Barcode as submitted, or `bb` plus 12 lowercase base36 characters for products without
        a barcode or submitted with a `variant`.
      pattern: ^([0-9]{8}|[0-9]{12,14}|bb[0-9a-z]{12})$
      examples:
      - '8801043015509'
      - bb7k3q9m2xf4d1
    Error:
      type: object
      required:
      - code
      - message
      properties:
        code:
          type: string
          example: product_not_found
          description: Machine-readable code, for example `product_not_found` or `validation_error`. Match
            on this rather than on `message`.
        message:
          type: string
          description: Human-readable explanation, in English.
        details:
          type: array
          description: Per-field problems for validation errors. `field` is the JSON path of the offending
            field; `issue` says what is wrong. Omitted for other errors.
          items:
            type: object
            properties:
              field:
                type: string
              issue:
                type: string
    StageStatus:
      type: string
      enum:
      - pending
      - processing
      - done
      description: '`pending`: not started yet. `processing`: queued or running. `done`: output available.'
    ProductStatus:
      type: object
      description: Processing status. `sentiment` starts on its first read and stays `pending` until then;
        a methodology update restarts it when it was `done`.
      required:
      - sentiment
      properties:
        sentiment:
          $ref: '#/components/schemas/StageStatus'
          description: '`GET /products/{id}/consumer-sentiment` has been collected. Starts on its first
            read.'
    ProductSummary:
      type: object
      description: Lightweight product record used in lists.
      required:
      - id
      - name
      - status
      - version
      properties:
        id:
          $ref: '#/components/schemas/Id'
        barcode:
          type: string
          description: Barcode as submitted. Absent for products without one.
          x-source: raw
        variant:
          type: string
          description: Variant key as submitted. Absent when none was submitted.
          x-source: raw
        name:
          $ref: '#/components/schemas/Text'
        brand:
          type: string
          x-source: raw
        status:
          $ref: '#/components/schemas/ProductStatus'
        version:
          type: integer
    StageProcessing:
      type: object
      description: 202 body of a stage endpoint read before the stage is `done`.
      required:
      - status
      properties:
        status:
          $ref: '#/components/schemas/StageStatus'
          description: Always `processing`, the read itself starts the stage.
    Text:
      type: object
      description: Human-readable text keyed by ISO 639-1 language code. At least one key is present.
        `en` and `nl` are produced today; other languages may be added.
      minProperties: 1
      properties:
        en:
          type: string
          description: English.
        nl:
          type: string
          description: Dutch.
      additionalProperties:
        type: string
        description: Any other ISO 639-1 language code.
    ConsumerAttribute:
      type: object
      required:
      - label
      - bucket
      - summary
      - quotes
      properties:
        label:
          $ref: '#/components/schemas/Text'
          description: Short name of the attribute, for example "spiciness".
        bucket:
          type: string
          enum:
          - love
          - desire
          - complaint
          description: '`love`: what consumers praise. `desire`: what they wish for. `complaint`: what
            they dislike.'
        summary:
          $ref: '#/components/schemas/Text'
          description: One or two sentences summarising the attribute. Quotes stay in their original language.
        quotes:
          type: array
          items:
            $ref: '#/components/schemas/ConsumerQuote'
          description: Verbatim quotes that support the attribute, in their original language.
    ConsumerQuote:
      type: object
      required:
      - text
      - kind
      properties:
        text:
          type: string
        url:
          type: string
          format: uri
          description: Link to the quote at its source.
        kind:
          type: string
          description: Source the quote was collected from, for example `amazon`, `walmart`, `tiktok`,
            `instagram`, `reddit`, `youtube`, or `web`. New sources may be added.
        posted_at:
          type: string
          format: date-time
          description: When the quote was posted. Omitted when the source does not expose a date.
        engagement:
          type: object
          description: Engagement counts on the source platform. Only the counts the platform exposes
            are present. Omitted when the source has none.
          properties:
            likes:
              type: integer
              minimum: 0
            score:
              type: integer
              description: Reddit score.
            views:
              type: integer
              minimum: 0
    ProductMetadataInput:
      type: object
      description: The product as the partner knows it. `brand` and `name` are what bananabread searches
        reviews and posts for.
      required:
      - name
      - brand
      - lang
      properties:
        barcode:
          type: string
          description: Barcode as printed, digits only, 8, 12, 13 or 14 digits (GTIN-8/12/13/14). Used
            as the product `id` unless `variant` is given. Other lengths and invalid check digits are
            rejected with 400. Omit for products without a barcode; a `bb` id is assigned.
        variant:
          type: string
          maxLength: 64
          description: Distinguishes several products that share one barcode, for example a supplier code.
            When given, the product receives a `bb` id instead of the barcode. Omit when one barcode means
            one product.
        name:
          type: string
          description: Product name as printed, in the label language.
        brand:
          type: string
          description: Brand as printed on the label.
        lang:
          type: string
          enum:
          - en
          - nl
          description: Language of the submitted text.
        description:
          type: string
          description: Product description as printed, in the label language. Helps tell the product apart
            from others with a similar name.
  examples:
    ShinRamyunList:
      summary: Partner catalogue with one product
      value:
        items:
        - id: '8801043015509'
          barcode: '8801043015509'
          name:
            en: Shin Ramyun
            nl: Shin Ramyun
          brand: Nongshim
          status:
            sentiment: done
          version: 1
        total: 1
    ShinRamyunInput:
      summary: Shin Ramyun (8801043015509) as a partner submits it
      value:
        barcode: '8801043015509'
        name: Shin Ramyun
        brand: Nongshim
        lang: en
        description: Spicy instant noodle soup (noodles + soup powder + dried vegetable mix)
