openapi: 3.1.0

info:
  title: DYJ Marketplace Partner API
  version: "1.0.0"
  summary: Server-to-server access to the jewellery products your feeds select.
  description: |
    This API answers one question: **what is currently in my feed, and what does it cost me?**

    You do not pick products. You define *rules* in your DYJ dashboard — categories, a cost
    band, and a margin schedule — and the feed is whatever satisfies them right now. A designer
    listing a new ring inside your band appears here without either of you doing anything; a
    delisted one disappears the same way. There is nothing to synchronise and nothing that can
    go stale, because membership is recomputed on every request rather than stored.

    Two consequences worth planning for:

    * **Products can disappear between two calls.** That is normal, not an error. Treat a
      product that has vanished from search as out of stock rather than as a failure.
    * **Prices are recomputed too.** `retailMinor` is derived from the designer's current cost
      and your current margin schedule. Cache it for minutes, not days.

    Every amount in this API is in **minor units** — cents. `150000` is €1,500.00. There is no
    endpoint that speaks euros, deliberately: mixing the two is the single most common way an
    integration ends up off by a factor of a hundred.
  contact:
    name: DYJ.one
    url: https://dyj.one

servers:
  - url: https://api.dyj.one/v1
    description: Production

security:
  - SecretKey: []

tags:
  - name: Feeds
    description: Reading the products your rules select.
  - name: Service
    description: Liveness.

paths:
  /market/feeds/{feedId}/items/search:
    post:
      tags: [Feeds]
      summary: List the products currently in a feed
      operationId: searchFeedItems
      description: |
        Returns the products your feed's rules select, each priced by the first rule that
        matches it, newest cursor last.

        **Filters narrow; they never widen.** Every field in the request body is applied *on
        top of* your rules. There is no parameter that can reach a product your rules do not
        already select — asking for a category no rule covers returns an empty list, not an
        error.

        A `POST` for a read is deliberate: the filters are structured, and putting them in a
        body keeps them out of proxy logs that sit next to your `Authorization` header.
      parameters:
        - $ref: '#/components/parameters/FeedId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
            examples:
              everything:
                summary: The whole feed, first page
                value: {}
              narrowed:
                summary: Rings only, at least €1,000.00 of cost
                value:
                  categories: [ring]
                  costMinMinor: 100000
              nextPage:
                summary: The page after a previous response
                value:
                  cursor: 412
                  limit: 100
      responses:
        '200':
          description: The current contents of the feed.
          headers:
            Cache-Control:
              schema: { type: string, examples: ['private, max-age=60'] }
              description: Membership is live, but need not be fetched more than once a minute.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
              examples:
                oneProduct:
                  value:
                    items:
                      - id: 2b0478f5-8ef3-41e0-9f1c-a0dcc9a2d993
                        title: Colette solitaire
                        description: A narrow-shouldered solitaire.
                        category: ring
                        tags: [solitaire, bridal]
                        materialsNote: 18k white gold
                        costMinor: 150000
                        retailMinor: 181500
                        currency: EUR
                        matchedRuleId: 2
                        updatedAt: '2026-08-31T18:10:43.594543+00:00'
                    nextCursor: null
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404': { $ref: '#/components/responses/FeedNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /market/feeds/{feedId}/items/{listingId}:
    get:
      tags: [Feeds]
      summary: Fetch one product from a feed
      operationId: getFeedItem
      description: |
        Returns a single product, priced exactly as search prices it.

        A product is reachable by id **only if your rules still select it**. A product that
        exists on the marketplace but is not in your feed answers `404`, identically to one
        that does not exist at all — so this endpoint cannot be used to discover what else is
        on the platform.
      parameters:
        - $ref: '#/components/parameters/FeedId'
        - name: listingId
          in: path
          required: true
          description: The product's `id` from a search response.
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '404':
          description: |
            No such product in this feed. Returned for an unknown id, a product outside your
            feed, and a delisted one alike.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                notFound:
                  value: { error: not_found, message: Product not found }
        '429': { $ref: '#/components/responses/RateLimited' }

  /health:
    get:
      tags: [Service]
      summary: Liveness
      operationId: health
      security: []
      description: Unauthenticated. Reports that the API process is serving; it does not probe the database.
      responses:
        '200':
          description: Serving.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: [ok] }
                  service: { type: string, examples: [client-api] }

components:
  securitySchemes:
    SecretKey:
      type: http
      scheme: bearer
      description: |
        Your **secret** partner key, `dyj_sk_` followed by 48 hex characters. Generate and
        rotate it under *My Account → Developer* in the DYJ dashboard.

        It is shown **once**, at creation. DYJ stores only a SHA-256 hash and cannot recover
        it — if you lose it, rotate.

        This key must live on your server and nowhere else. It is not the same credential as
        the `dyj_live_` embed key, which is designed for browsers and is rejected here. If
        your storefront is static, put a small proxy in front of this API rather than shipping
        the key to the client. Rotation invalidates the previous key immediately; there is no
        overlap window.

  parameters:
    FeedId:
      name: feedId
      in: path
      required: true
      description: |
        Your feed's public id, from the Integration tab of the feed in your dashboard. It
        identifies a feed; it does not authorise access — the secret key does that.
      schema: { type: string, format: uuid }

  schemas:
    SearchRequest:
      type: object
      additionalProperties: false
      description: All fields optional. Every one narrows the result; none can widen it.
      properties:
        categories:
          type: array
          maxItems: 8
          items: { $ref: '#/components/schemas/Category' }
          description: Restrict to these categories. Categories your rules do not cover simply match nothing.
        costMinMinor:
          type: integer
          minimum: 0
          description: Minimum cost price, in cents, inclusive.
        costMaxMinor:
          type: integer
          minimum: 0
          description: Maximum cost price, in cents, inclusive.
        cursor:
          type: integer
          minimum: 1
          description: The `nextCursor` from the previous page.
        limit:
          type: integer
          minimum: 1
          maximum: 200
          default: 50

    SearchResponse:
      type: object
      required: [items, nextCursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Product' }
        nextCursor:
          type: [integer, 'null']
          description: |
            Pass as `cursor` to fetch the next page. `null` means this was the last page —
            keep paging only while it is non-null, and never assume a fixed page count.

    Product:
      type: object
      required: [id, title, category, tags, costMinor, retailMinor, currency, matchedRuleId, updatedAt]
      properties:
        id:
          type: string
          format: uuid
          description: Stable identifier for this product. Use it as your own catalogue key.
        title: { type: string }
        description: { type: [string, 'null'] }
        category: { $ref: '#/components/schemas/Category' }
        tags:
          type: array
          items: { type: string }
        materialsNote:
          type: [string, 'null']
          description: Free text from the designer, e.g. "18k white gold".
        costMinor:
          type: integer
          description: |
            What DYJ charges you to produce this piece, in cents. This is your cost, not a
            recommended price, and it is confidential to your account.
        retailMinor:
          type: integer
          description: |
            Your selling price, in cents, computed from `costMinor` by the margin schedule of
            the rule named in `matchedRuleId`:

            `retail = round_half_up(cost × (1 + percent/100)) + addon`

            No other account can see it, and DYJ never charges it — it exists so your store and
            your dashboard agree without you recomputing anything.
        currency:
          type: string
          examples: [EUR]
          description: EUR only in v1.
        matchedRuleId:
          type: integer
          description: |
            Which of your rules priced this product. Rules are ordered and the FIRST match
            wins, so this tells you why a product costs what it does.
        updatedAt:
          type: string
          format: date-time

    Category:
      type: string
      enum: [ring, earrings, bracelet, necklace, pendant, brooch, cufflinks, other]

    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: A stable machine-readable code. Branch on this, not on the message.
        message:
          type: string
          description: Human-readable. May be reworded without notice.

  responses:
    BadRequest:
      description: |
        The request body did not validate. Unknown fields are rejected rather than ignored —
        a silently dropped filter would let you believe it applied.
      content:
        application/json:
          schema:
            type: object
            properties:
              message:
                type: array
                items: { type: string }
              error: { type: string }
              statusCode: { type: integer }
          examples:
            unknownField:
              value:
                message: ['property listingIds should not exist']
                error: Bad Request
                statusCode: 400

    Unauthorized:
      description: |
        Missing, malformed, unknown, or revoked key — all four answer identically, on purpose.
        The remedy is the same in every case: check the key, or rotate it.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string, examples: [Invalid API key] }
              error: { type: string }
              statusCode: { type: integer }

    PaymentRequired:
      description: Your DYJ plan is not live. The feed resumes the moment it is.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            lapsed:
              value: { error: plan_required, message: This account has no live plan }

    FeedNotFound:
      description: |
        No such feed, or not yours, or disabled. Merged deliberately: an authenticated stranger
        must not be able to learn that a feed id exists.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound:
              value: { error: feed_not_found, message: Feed not found }
            disabled:
              value: { error: feed_disabled, message: This feed is disabled }

    RateLimited:
      description: |
        Too many requests for this key. Limits are per **key**, not per IP, so adding servers
        does not add quota. Back off and retry; a full catalogue sync should page steadily
        rather than fan out.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string, examples: [Rate limit exceeded] }
              statusCode: { type: integer, examples: [429] }
