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

# Send a finalized sale's receipt to the customer

> Delivers the receipt for one FINALIZED sale to a real person over `email` or `sms`. This leaves the system: it hands the message to the ESP and, for SMS, DEBITS the account credit wallet. It cannot be recalled and there is no delete endpoint, so only send on an explicit request naming the sale and the channel. Identify the sale with `saleId` (the finalized-sale id) or `transactionId` — at least one is required. `recipient` overrides the customer's on-file contact; omit it to use the contact attached to the ticket, and expect a 400 `no recipient` when the ticket is anonymous or the contact is missing. A receipt is transactional (lawful basis: the sale contract), so it is NOT gated on marketing consent. Read `status`: `sent` (the ESP accepted it, `providerMessageId` carries the correlation id), `failed` (rejected; nothing was charged) or `dry_run` (no ESP key on this deployment — the customer received NOTHING). NOT IDEMPOTENT at the boundary. A repeat of the same `(saleId, channel, recipient)` is normally deduped against the usage log and returns the earlier outcome without re-sending, BUT that log row is written AFTER the ESP call: a request that times out mid-send leaves no row, so a blind retry sends and charges a SECOND time. On a timeout, do not retry — check with the operator. Changing `recipient` also defeats the dedup, and a `dry_run` row still counts as a prior send, so a receipt dry-run on a keyless environment permanently suppresses the real send for that triple. ERROR-VOCABULARY TRAP: the specific failure is carried in `error.message`, not in the `error.code` enum. 422 `sms-unavailable` (SMS switched off network-wide), 422 `insufficient-credits`, 422 `email-quota-exceeded`, 422 `rate-limited` (the per-account 300-per-minute or per-shop 120-per-minute ceiling — semantically a 429, but this API has no 429), 404 `sale not found`, 400 `no recipient`. Branch on the message token.



## OpenAPI

````yaml /openapi.json post /v1/notifications/receipt
openapi: 3.0.3
info:
  title: Solya POS API
  version: 1.0.0
  description: >-
    The Solya POS backend HTTP surface. Every documented operation is
    agent-ready: it carries an `operationId`, an agent-facing `description`, the
    `pos.*` scopes it enforces (`x-required-permissions`) and an `x-agent-tier`.
    Success responses return the payload as raw JSON; failures return the
    `ErrorResponse` envelope (`{ error: { code, message, statusCode } }`).
servers:
  - url: /
    description: The backend, relative to its deployed origin.
security: []
paths:
  /v1/notifications/receipt:
    post:
      tags:
        - Notifications
      summary: Send a finalized sale's receipt to the customer
      description: >-
        Delivers the receipt for one FINALIZED sale to a real person over
        `email` or `sms`. This leaves the system: it hands the message to the
        ESP and, for SMS, DEBITS the account credit wallet. It cannot be
        recalled and there is no delete endpoint, so only send on an explicit
        request naming the sale and the channel. Identify the sale with `saleId`
        (the finalized-sale id) or `transactionId` — at least one is required.
        `recipient` overrides the customer's on-file contact; omit it to use the
        contact attached to the ticket, and expect a 400 `no recipient` when the
        ticket is anonymous or the contact is missing. A receipt is
        transactional (lawful basis: the sale contract), so it is NOT gated on
        marketing consent. Read `status`: `sent` (the ESP accepted it,
        `providerMessageId` carries the correlation id), `failed` (rejected;
        nothing was charged) or `dry_run` (no ESP key on this deployment — the
        customer received NOTHING). NOT IDEMPOTENT at the boundary. A repeat of
        the same `(saleId, channel, recipient)` is normally deduped against the
        usage log and returns the earlier outcome without re-sending, BUT that
        log row is written AFTER the ESP call: a request that times out mid-send
        leaves no row, so a blind retry sends and charges a SECOND time. On a
        timeout, do not retry — check with the operator. Changing `recipient`
        also defeats the dedup, and a `dry_run` row still counts as a prior
        send, so a receipt dry-run on a keyless environment permanently
        suppresses the real send for that triple. ERROR-VOCABULARY TRAP: the
        specific failure is carried in `error.message`, not in the `error.code`
        enum. 422 `sms-unavailable` (SMS switched off network-wide), 422
        `insufficient-credits`, 422 `email-quota-exceeded`, 422 `rate-limited`
        (the per-account 300-per-minute or per-shop 120-per-minute ceiling —
        semantically a 429, but this API has no 429), 404 `sale not found`, 400
        `no recipient`. Branch on the message token.
      operationId: sendSaleReceipt
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                saleId:
                  type: string
                  minLength: 1
                transactionId:
                  type: string
                  minLength: 1
                channel:
                  type: string
                  enum:
                    - email
                    - sms
                recipient:
                  type: string
                  minLength: 1
              required:
                - channel
              example:
                saleId: sale-2026-0142
                channel: email
                recipient: camille.renard@example.com
      responses:
        '200':
          description: >-
            The delivery outcome. A 200 means the request was PROCESSED, not
            that the customer received anything — read `status`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - sent
                      - failed
                      - dry_run
                  providerMessageId:
                    type: string
                    minLength: 1
                required:
                  - status
                additionalProperties: false
                description: >-
                  The delivery outcome. A 200 means the request was PROCESSED,
                  not that the customer received anything — read `status`.
                example:
                  status: dry_run
        '400':
          description: >-
            The request failed schema validation; `error.fieldErrors` lists the
            fields.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '401':
          description: No valid credential was presented — send a bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The actor is authenticated but lacks the required `pos.*` scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No resource matches the addressed identifier.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: The request is well-formed but violates a domain rule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: An unexpected server error — safe to retry idempotent requests.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - bearerAuth: []
components:
  schemas:
    ValidationErrorResponse:
      type: object
      required:
        - error
      additionalProperties: false
      description: >-
        A `VALIDATION_FAILED` envelope carrying the offending fields in
        `fieldErrors`.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - statusCode
          additionalProperties: false
          properties:
            code:
              type: string
              enum:
                - VALIDATION_FAILED
            message:
              type: string
            statusCode:
              type: integer
            fieldErrors:
              type: array
              description: >-
                One entry per rejected field: the field path and why it was
                rejected.
              items:
                type: object
                required:
                  - field
                  - message
                additionalProperties: false
                properties:
                  field:
                    type: string
                    description: Dot-path of the offending field.
                  message:
                    type: string
                    description: Why the field was rejected.
    ErrorResponse:
      type: object
      required:
        - error
      additionalProperties: false
      description: The uniform failure envelope every non-2xx response returns.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - statusCode
          additionalProperties: false
          properties:
            code:
              type: string
              enum:
                - VALIDATION_FAILED
                - UNAUTHORIZED
                - FORBIDDEN
                - NOT_FOUND
                - CONFLICT
                - BUSINESS_RULE_VIOLATION
                - INTERNAL_ERROR
              description: >-
                Machine-readable kernel `ResultCode` — branch on this, not on
                `message`.
            message:
              type: string
              description: >-
                Human-readable explanation. Safe to surface; never leaks server
                internals.
            statusCode:
              type: integer
              description: >-
                The HTTP status, mirrored into the body so a client need not
                read headers.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        `Authorization: Bearer <token>`. Accepts EITHER a Keycloak access token
        (scopes-in-token) OR an opaque POS session token; both resolve to the
        same `pos.*` scope vocabulary the route guards enforce.

````