> ## Documentation Index
> Fetch the complete documentation index at: https://docs.printools.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Add a group to a quote

> Requires the scope quotes.write.

Required scopes: `quotes.write`.


## OpenAPI

````yaml POST /v1/quotes/{quoteId}/groups
openapi: 3.1.0
info:
  title: PRINTOOLS API
  version: 1.0.0
  description: >-
    The PRINTOOLS public REST API. This document is generated from the live
    route table and service GraphQL SDL; do not edit it by hand.


    Every 2xx response has {status, message, data}. Every non-2xx response has
    {error, message, status: false}.


    Tenant credential: send X-API-Key only. It is bound to one shop.
    X-PrintTools-Organization-Id is optional for that credential and, if sent,
    must match its shop.


    Partner credential: send X-API-Key, X-API-Secret, Authorization: Bearer
    <Cognito access token>, and X-PrintTools-Organization-Id. The OAuth flow is
    documented at https://app.printools.io/api/index.html#partner-auth.


    Read live limits from GET /v1/reference/limits. Field shapes are projected
    from operation declarations; local endpoints have explicit handler-derived
    schemas.
servers:
  - url: https://api.printools.io
    description: Production
security: []
externalDocs:
  description: Getting started, OAuth and webhook guidance
  url: https://app.printools.io/api/index.html
paths:
  /v1/quotes/{quoteId}/groups:
    post:
      tags:
        - quotes
      summary: Add a group to a quote.
      description: Requires the scope quotes.write.
      operationId: post_v1_quotes_quoteId_groups
      parameters:
        - name: quoteId
          in: path
          required: true
          schema:
            type: string
            format: uuid
            example: 11111111-2222-3333-4444-555555555555
          description: UUID.
        - name: X-PrintTools-Organization-Id
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: >-
            Tenant credential: optional and, when sent, must equal the shop
            bound to the key. Partner credential: required; it selects an
            organisation the user previously authorised.
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 200
          description: >-
            Optional client-generated key for write retries. Reusing it with a
            different request returns 409; a replay includes
            Idempotency-Replayed: true.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                sortOrder:
                  type: integer
              required:
                - name
            examples:
              example:
                summary: Replace example values with a request for your shop.
                value:
                  name: example
                  sortOrder: 1
      responses:
        '201':
          description: Successful response.
          headers:
            Idempotency-Replayed:
              description: >-
                Present and true when this response was replayed from a prior
                Idempotency-Key request.
              schema:
                type: string
                enum:
                  - 'true'
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Success'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/Output_QuoteGroup'
        '400':
          description: >-
            Validation or request-shape error. Correct the request before
            retrying.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Credential authentication failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers:
            WWW-Authenticate:
              schema:
                type: string
                example: ApiKey
        '403':
          description: >-
            The credential lacks a required scope, or its actor lacks
            permission.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The resource or endpoint was not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            Conflict, including idempotency-key reuse or an in-flight idempotent
            request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The JSON request body exceeds the published size limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Rate limited. Retry after the returned delay.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers:
            Retry-After:
              description: Whole seconds to wait.
              schema:
                type: integer
                minimum: 0
        '500':
          description: An unexpected server error. A retry can be appropriate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - tenantApiKey: []
        - partnerApiKey: []
          partnerApiSecret: []
          partnerBearer: []
      x-codeSamples:
        - lang: curl
          label: Tenant credential
          source: >-
            curl --request POST
            'https://api.printools.io/v1/quotes/11111111-2222-3333-4444-555555555555/groups'
            \
              --header 'X-API-Key: $PRINTOOLS_API_KEY' \
              --header 'Content-Type: application/json' \
              --data '{"name":"example","sortOrder":1}'
components:
  schemas:
    Success:
      type: object
      required:
        - status
        - message
        - data
      properties:
        status:
          type: boolean
          description: >-
            The actual resolver result. It can be false when an operation
            returns false or null without throwing.
        message:
          type: string
          example: OK
        data: {}
      description: Every 2xx response uses this envelope.
    Output_QuoteGroup:
      type: object
      properties:
        id:
          type: string
        quoteId:
          type: string
        name:
          type: string
        totalQuantity:
          type: integer
        sortOrder:
          type: integer
        rollUpDecoration:
          type: boolean
        rollUpSetup:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        lines:
          type: array
          items:
            $ref: '#/components/schemas/Output_QuoteLine'
          description: >-
            `lines` and `decorations` are two collections sharing ONE
            `sortOrder`

            sequence (F-26). To render a group in the operator's order, merge
            them and

            sort by `sortOrder` — do not concatenate lines-then-decorations,
            which is

            what made a surcharge unable to sit below a decoration. To reorder,
            renumber

            across BOTH collections.
        decorations:
          type: array
          items:
            $ref: '#/components/schemas/Output_QuoteGroupDecoration'
      required:
        - id
        - quoteId
        - name
        - totalQuantity
        - sortOrder
        - rollUpDecoration
        - rollUpSetup
        - createdAt
        - updatedAt
        - lines
        - decorations
    Error:
      type: object
      required:
        - error
        - message
        - status
      properties:
        error:
          type: string
          description: Stable machine-readable error code.
        message:
          type: string
        status:
          type: boolean
          const: false
      description: Every non-2xx response uses this envelope.
    Output_QuoteLine:
      type: object
      properties:
        id:
          type: string
        quoteGroupId:
          type: string
        lineType:
          $ref: '#/components/schemas/Enum_QuoteLineType'
        garmentCatalogueItemId:
          type:
            - string
            - 'null'
        productName:
          type: string
        productSku:
          type:
            - string
            - 'null'
        title:
          type:
            - string
            - 'null'
        description:
          type:
            - string
            - 'null'
        quantity:
          type: integer
        unitPriceExGst:
          type: number
        valueType:
          anyOf:
            - $ref: '#/components/schemas/Enum_LineValueType'
            - type: 'null'
          description: >-
            Flat or Percentage. Applies to a Discount AND (F-26) a Surcharge — a
            rate on

            a line, whichever direction it points.
        percentage:
          type:
            - number
            - 'null'
          description: The rate, when valueType is Percentage. Null otherwise.
        discountValueType:
          anyOf:
            - $ref: '#/components/schemas/Enum_DiscountValueType'
            - type: 'null'
        discountPercentage:
          type:
            - number
            - 'null'
        customImageUrl:
          type:
            - string
            - 'null'
          description: Per-line image override; null falls back to the catalogue thumbnail.
        sortOrder:
          type: integer
          description: >-
            Position in the group's row sequence — shared with the group's
            DECORATIONS

            since F-26, so a surcharge or discount can sit below a decoration.
            Omit on

            create to append.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        variants:
          type: array
          items:
            $ref: '#/components/schemas/Output_QuoteVariant'
      required:
        - id
        - quoteGroupId
        - lineType
        - productName
        - quantity
        - unitPriceExGst
        - sortOrder
        - createdAt
        - updatedAt
        - variants
    Output_QuoteGroupDecoration:
      type: object
      properties:
        id:
          type: string
        quoteGroupId:
          type: string
        decorationItemId:
          type:
            - string
            - 'null'
        method:
          anyOf:
            - $ref: '#/components/schemas/Enum_JobDecorationMethod'
            - type: 'null'
          description: >-
            The decoration technique. Recorded from the quote stage since
            CU-86d43a1qr —

            it is agreed with the customer when the price is, not discovered on
            the floor.

            Same enum as `JobGroupDecoration.method`, and literally the same
            Postgres

            type, so conversion copies it.
        position:
          type:
            - string
            - 'null'
          description: >-
            Where the print goes — FREE TEXT (CU-86d4571me).


            Was a nine-value enum whose escape hatch was `Other`, which could
            not carry

            what the "other" actually was: "2cm down from neckline", "side of
            caps",

            "over pocket", "under collar". An escape hatch that cannot hold the

            explanation is not one, and the shop floor was reading a bare
            "Other".


            It also ABSORBS the old `placement`, which was a second free-text
            field for

            the same fact ("2cm below collar", "centred on pocket"). Two fields
            meant two

            places to look and two chances to disagree; there is one now.


            The nine former values (Front, Back, Left Sleeve, Right Sleeve,
            Neck,

            Lower Back, Left Chest, Right Chest, Other) are CLIENT-SIDE
            SUGGESTIONS

            ONLY — the server constrains nothing but trims the value and caps
            its

            length. Stored as the display label, so the job, the proof, the PDF,
            the

            portal and the job sheet all show the same string.


            Null means no position has been named. Empty is refused rather than
            stored.
        quantity:
          type: integer
        setupCost:
          type: number
          description: Present only when the credential includes the costs.read scope.
        unitCost:
          type: number
          description: Present only when the credential includes the costs.read scope.
        baseUnitCost:
          type: number
        baseSetupCost:
          type: number
        includeSetupCost:
          type: boolean
        markupType:
          anyOf:
            - $ref: '#/components/schemas/Enum_MarkupType'
            - type: 'null'
          description: >-
            The markup connecting the per-unit COST to the per-unit CHARGE,
            stored so it

            survives a reopen (CU-86d46hu37).


            A decoration used to persist nothing here: the client back-solved a
            markup

            from cost and charge every time the dialog opened, which is why a
            decoration

            saved with a PERCENTAGE markup always reopened showing a flat dollar
            amount —

            a back-solved percentage is rarely exact, so the back-solve had to
            prefer

            flat. Null on rows written before these columns existed; those still

            back-solve, exactly as every row did before.
        markupValue:
          type:
            - number
            - 'null'
        markupSource:
          $ref: '#/components/schemas/Enum_MarkupSource'
          description: >-
            Who owns the per-unit markup — see `MarkupSource`. `group` re-bands
            on every

            quantity or cost move; `manual` never does.
        setupMarkupType:
          anyOf:
            - $ref: '#/components/schemas/Enum_MarkupType'
            - type: 'null'
          description: >-
            The same three facts for the SETUP lane, which is a genuinely
            separate

            markup: a setup markup group is matched by `appliesToSetupCost`
            (exclusive of

            the per-unit groups) and bands on the SETUP cost, not the per-unit
            one. One

            lane can be group-owned while the other is not.


            Inert unless `includeSetupCost` — there is no charge to mark up when
            the

            setup is not being charged.
        setupMarkupValue:
          type:
            - number
            - 'null'
        setupMarkupSource:
          $ref: '#/components/schemas/Enum_MarkupSource'
        rollUp:
          type: boolean
          description: >-
            Fold this decoration's UNIT charge into the garment unit price
            rather than

            pricing it on its own row (CU-86d416yvz).


            PER DECORATION. `QuoteGroup.rollUpDecoration` folded every
            decoration in the

            group or none of them, which made the dialog's per-decoration toggle
            a lie —

            turning it on for the embroidery also folded the screen print. The
            group

            flags are still readable this release and no longer feed the
            calculation.


            Display only: the group subtotal, the quote total and the margin are
            the

            same number either way. What changes is where the customer reads the
            money.
        rollUpSetup:
          type: boolean
          description: >-
            Same for the SETUP charge. Inert unless `includeSetupCost` is true —
            there is

            nothing to fold when the setup is not being charged, the pairing the
            group

            flag already had.
        showOnDocuments:
          type: boolean
          description: >-
            Draw this decoration's row on customer-facing documents at all.


            Distinct from `rollUp`, and they answer different questions — the
            quote

            document, the PDF and the portal all honour both:

              rollUp only        the row is listed, its price reads "Included"
              rollUp + hidden    no row anywhere; the money is inside the garment price

            Hidden with `rollUp: false` would leave the itemised rows summing to
            less

            than the customer pays, so hiding a row implies the fold.


            Defaults true — every decoration prints today and this must not
            change how a

            live quote reads. Note the opposite default to
            `QuoteVariant.showOnDocuments`,

            which folds by default because a courier fee itemised as a garment
            variant

            was the complaint there.
        description:
          type:
            - string
            - 'null'
        widthCm:
          type:
            - number
            - 'null'
          description: >-
            Print dimensions in CENTIMETRES (BE-04 / Q-24 / CU-86d43a1qr). The
            unit is

            fixed by the schema, not stored per row — renderers print "30 × 25
            cm"; the

            column only ever holds the number. Nullable: most decorations carry
            no

            dimensions, and 0 is a measurement, not an absence.


            These were `width`/`height` in millimetres until CU-86d43a1qr, while
            the proof

            spec describing the same print stored centimetres — so one job
            printed

            `300 × 250 mm` on the quote and `30 × 25 cm` on the proof. Every
            stage stores

            cm now and nothing converts.
        lengthCm:
          type:
            - number
            - 'null'
        necklineDropCm:
          type:
            - number
            - 'null'
          description: How far below the collar the print starts, in cm.
        screenCount:
          type:
            - integer
            - 'null'
          description: Screens to burn — screen printing only. Null when it does not apply.
        screenMeshGrade:
          type:
            - string
            - 'null'
          description: Mesh grade for those screens, e.g. `156`.
        colourCount:
          type:
            - integer
            - 'null'
          description: >-
            How many ink colours the print uses. Free of `colours` — an operator
            can price a 4-colour print before naming the inks.
        colours:
          type: array
          items:
            $ref: '#/components/schemas/Output_DecorationColour'
          description: >-
            The inks, in the operator's order. At most one is flagged `isBase`,
            and it renders first.
        specialInstructions:
          type:
            - string
            - 'null'
          description: >-
            Internal shop notes — **never customer-facing**. Named to match

            `JobGroupDecoration.specialInstructions` so quote and job share one

            vocabulary. `description` is the Title and prints on the quote
            document, so

            it could not be reused for this. Deliberately absent from

            `PortalQuoteDecoration` and from the PDF renderer — internal by

            construction, the way `Mock.layers` is withheld from `PortalMock`.
        artworkFileUrl:
          type:
            - string
            - 'null'
        separationFileUrl:
          type:
            - string
            - 'null'
        sortOrder:
          type: integer
          description: >-
            Position in the group's row sequence — the SAME sequence as the
            group's

            LINES since F-26, not a separate one. Omit on create to append.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - quoteGroupId
        - quantity
        - baseUnitCost
        - baseSetupCost
        - includeSetupCost
        - markupSource
        - setupMarkupSource
        - rollUp
        - rollUpSetup
        - showOnDocuments
        - colours
        - sortOrder
        - createdAt
        - updatedAt
    Enum_QuoteLineType:
      type: string
      enum:
        - Garment
        - Decoration
        - Service
        - Cost
        - Discount
        - Surcharge
        - Other
    Enum_LineValueType:
      type: string
      enum:
        - Flat
        - Percentage
    Enum_DiscountValueType:
      type: string
      enum:
        - Flat
        - Percentage
    Output_QuoteVariant:
      type: object
      properties:
        id:
          type: string
        quoteLineId:
          type: string
        size:
          type:
            - string
            - 'null'
        colour:
          type:
            - string
            - 'null'
        quantity:
          type: integer
        unitPriceExGst:
          type: number
        buyPrice:
          type: number
          description: Present only when the credential includes the costs.read scope.
        extraCost:
          type: number
          description: Present only when the credential includes the costs.read scope.
        markupType:
          anyOf:
            - $ref: '#/components/schemas/Enum_MarkupType'
            - type: 'null'
          description: >-
            The markup that connects cost to sell, stored so it survives a
            reopen

            (BE-01 / Q-16). Cost is `buyPrice + extraCost`; sell is

            `percentage ? cost + cost·value/100 : cost + value`. Null on rows
            written

            before the column existed — read those back by back-solving from
            cost and

            sell, as the client did for every row previously. `MarkupType` is
            the enum

            catalog-service already declares.
        markupValue:
          type:
            - number
            - 'null'
        markupSource:
          $ref: '#/components/schemas/Enum_MarkupSource'
          description: >-
            Whether the markup above belongs to a markup GROUP or to the
            operator

            (CU-86d46hu37). `group` re-resolves through the group's matrix
            whenever the

            quantity or the cost moves — on reopen as much as on create — while
            `manual`

            is left alone and holds its saved sell price through a cost edit.


            Never null: rows written before the column existed read `manual`,
            which is

            precisely how they behaved.
        isCustomLine:
          type: boolean
          description: >-
            A cost that belongs to the line but is not per-garment — freight is
            the

            example (BE-02 / Q-14). Stored as a variant with no size and no
            colour; the

            flag is what tells one from a half-filled variant, since the size
            sort, the

            quote document and the PDF all walk these rows.
        showOnDocuments:
          type: boolean
          description: >-
            Whether a CUSTOM variant is itemised on the documents the customer
            sees —

            quote, invoice, PDF, portal (QU-02).


            False, the default, folds its money into the garment line's total
            and draws

            no row anywhere. True prints one row reading `Custom variant`,
            quantity 1,

            with size and colour blank. Either way the money is identical: a
            line's

            total already sums every variant beneath it, custom rows included.


            Ignored on a real size/colour variant — that row IS the itemisation.
        sortOrder:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - quoteLineId
        - quantity
        - unitPriceExGst
        - markupSource
        - isCustomLine
        - showOnDocuments
        - sortOrder
        - createdAt
        - updatedAt
    Enum_JobDecorationMethod:
      type: string
      enum:
        - ScreenPrinting
        - Embroidery
        - DtfTransfer
        - Dtg
        - Vinyl
        - Sublimation
        - HeatTransfer
        - Outsourced
        - Other
    Enum_MarkupType:
      type: string
      enum:
        - percentage
        - flat_rate
    Enum_MarkupSource:
      type: string
      description: >-
        WHO owns a markup, and therefore whether it may be re-resolved through
        its

        markup group's matrix (CU-86d46hu37).


        `group` — a markup group resolved the number, so it re-bands on every
        quantity

        or cost change, on create and on reopen alike; the sell price moves with
        it.

        `manual` — an operator typed the markup, flipped its \$/% toggle, or
        overrode

        the sell price. Never re-banded, and a cost edit back-solves to hold the
        saved

        sell fixed, which is the edit-lock that stops a live quote repricing
        itself.


        Stored `not null default 'manual'`, so every row written before this
        existed is

        operator-owned and behaves exactly as it did. A create path that means
        `group`

        must SAY `group` — relying on the default silently reintroduces the bug
        the

        enum exists to fix.
      enum:
        - group
        - manual
    Output_DecorationColour:
      type: object
      properties:
        name:
          type: string
        isBase:
          type: boolean
      required:
        - name
        - isBase
  securitySchemes:
    tenantApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Tenant credential. This is sufficient by itself.
    partnerApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Partner application client ID.
    partnerApiSecret:
      type: apiKey
      in: header
      name: X-API-Secret
      description: Partner application secret.
    partnerBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Cognito access token obtained through OAuth.

````